diff --git a/CHANGELOG.md b/CHANGELOG.md index b708252b..35081143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ This project follows [Keep a Changelog](https://keepachangelog.com/) and [Semant ### Fixed - +## [2.2.8] - 2025-09-30 +### Added +- Fix .env is not ignored by git when using --fix flag. + +### Changed +- No breaking changes. + +### Fixed +- Refactored codebase for better maintainability. + ## [2.2.7] - 2025-09-28 ### Added - Added warning on .env not ignored by .gitignore on default. diff --git a/package.json b/package.json index f7bfcbcf..d0338313 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dotenv-diff", - "version": "2.2.7", + "version": "2.2.8", "type": "module", "description": "Scan your codebase to find environment variables in use.", "bin": { diff --git a/src/cli/run.ts b/src/cli/run.ts index 3ef14a51..990255aa 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -1,7 +1,6 @@ import type { Command } from 'commander'; import fs from 'fs'; import path from 'path'; -import chalk from 'chalk'; import { normalizeOptions } from '../config/options.js'; import { discoverEnvFiles } from '../services/envDiscovery.js'; @@ -10,33 +9,8 @@ import { ensureFilesOrPrompt } from '../services/ensureFilesOrPrompt.js'; import { compareMany } from '../commands/compare.js'; import { type CompareJsonEntry, type Options } from '../config/types.js'; import { scanUsage } from '../commands/scanUsage.js'; - -/** - * Run the CLI program - * @param program The commander program instance - */ -export async function run(program: Command) { - program.parse(process.argv); - const opts = normalizeOptions(program.opts()); - - setupGlobalConfig(opts); - - // Route to appropriate command - if (opts.compare) { - await runCompareMode(opts); - } else { - await runScanMode(opts); - } -} - -/** - * Setup global configuration - */ -function setupGlobalConfig(opts: Options) { - if (opts.noColor) { - chalk.level = 0; // disable colors globally - } -} +import { printErrorNotFound } from '../ui/compare/printErrorNotFound.js'; +import { setupGlobalConfig } from '../ui/shared/setupGlobalConfig.js'; /** * Run scan-usage mode (default behavior) @@ -162,18 +136,7 @@ async function handleMissingFiles( if (opts.isCiMode) { // In CI mode, just show errors and exit - if (!envExists) { - console.error( - chalk.red(`❌ Error: --env file not found: ${path.basename(envFlag)}`), - ); - } - if (!exExists) { - console.error( - chalk.red( - `❌ Error: --example file not found: ${path.basename(exampleFlag)}`, - ), - ); - } + printErrorNotFound(envExists, exExists, envFlag, exampleFlag); process.exit(1); } else { // Interactive mode - try to prompt for file creation @@ -226,3 +189,21 @@ function outputResults( } process.exit(exitWithError ? 1 : 0); } + +/** + * Run the CLI program + * @param program The commander program instance + */ +export async function run(program: Command) { + program.parse(process.argv); + const opts = normalizeOptions(program.opts()); + + setupGlobalConfig(opts); + + // Route to appropriate command + if (opts.compare) { + await runCompareMode(opts); + } else { + await runScanMode(opts); + } +} diff --git a/src/commands/compare.ts b/src/commands/compare.ts index 2b8680c8..c4c1c181 100644 --- a/src/commands/compare.ts +++ b/src/commands/compare.ts @@ -1,13 +1,111 @@ import fs from 'fs'; import path from 'path'; -import chalk from 'chalk'; import { parseEnvFile } from '../core/parseEnv.js'; import { diffEnv } from '../core/diffEnv.js'; -import { warnIfEnvNotIgnored } from '../services/git.js'; +import { checkGitignoreStatus } from '../services/git.js'; import { findDuplicateKeys } from '../services/duplicates.js'; import { filterIgnoredKeys } from '../core/filterIgnoredKeys.js'; -import type { Category, CompareJsonEntry } from '../config/types.js'; +import type { + Category, + CompareJsonEntry, + ComparisonOptions, + FilePair, + ComparisonResult, + Filtered, +} from '../config/types.js'; +import { isAllOk } from '../core/helpers/isAllOk.js'; +import { updateTotals } from '../core/helpers/updateTotals.js'; import { applyFixes } from '../core/fixEnv.js'; +import { printFixTips } from '../ui/shared/printFixTips.js'; +import { printStats } from '../ui/compare/printStats.js'; +import { printDuplicates } from '../ui/shared/printDuplicates.js'; +import { printHeader } from '../ui/compare/printHeader.js'; +import { printAutoFix } from '../ui/compare/printAutoFix.js'; +import { printIssues } from '../ui/compare/printIssues.js'; +import { printSuccess } from '../ui/shared/printSuccess.js'; +import { printGitignoreWarning } from '../ui/shared/printGitignore.js'; + +/** + * Creates a category filter function based on options. + * fx: onlyFiltering({ only: ['missing', 'extra'] }) + * @param opts Comparison options + * @returns A function that filters categories + */ +function createCategoryFilter(opts: ComparisonOptions) { + const onlySet: Set | undefined = opts.only?.length + ? new Set(opts.only) + : undefined; + + return (category: Category) => !onlySet || onlySet.has(category); +} + +/** + * Parses and filters the environment and example files. + * @param envPath The path to the .env file + * @param examplePath The path to the .env.example file + * @param opts Comparison options + * @returns An object containing the parsed and filtered environment variables + */ +function parseAndFilter( + envPath: string, + examplePath: string, + opts: ComparisonOptions, +) { + const currentFull = parseEnvFile(envPath); + const exampleFull = parseEnvFile(examplePath); + + const currentKeys = filterIgnoredKeys( + Object.keys(currentFull), + opts.ignore, + opts.ignoreRegex, + ); + const exampleKeys = filterIgnoredKeys( + Object.keys(exampleFull), + opts.ignore, + opts.ignoreRegex, + ); + + return { + current: Object.fromEntries( + currentKeys.map((k) => [k, currentFull[k] ?? '']), + ), + example: Object.fromEntries( + exampleKeys.map((k) => [k, exampleFull[k] ?? '']), + ), + currentKeys, + exampleKeys, + }; +} + +/** + * Finds duplicate keys in the environment and example files. + * @param envPath The path to the .env file + * @param examplePath The path to the .env.example file + * @param opts Comparison options + * @param run A function that determines if a category should be included + * @returns An object containing arrays of duplicate keys for both files + */ +function findDuplicates( + envPath: string, + examplePath: string, + opts: ComparisonOptions, + run: (cat: Category) => boolean, +) { + if (opts.allowDuplicates || !run('duplicate')) + return { dupsEnv: [], dupsEx: [] }; + + const filterKey = (key: string) => + !opts.ignore.includes(key) && !opts.ignoreRegex.some((rx) => rx.test(key)); + + const dupsEnv = findDuplicateKeys(envPath).filter(({ key }) => + filterKey(key), + ); + const dupsEx = findDuplicateKeys(examplePath).filter(({ key }) => + filterKey(key), + ); + + return { dupsEnv, dupsEx }; +} /** * Compares multiple pairs of .env and .env.example files. @@ -16,28 +114,15 @@ import { applyFixes } from '../core/fixEnv.js'; * @returns An object indicating the overall comparison results. */ export async function compareMany( - pairs: Array<{ envName: string; envPath: string; examplePath: string }>, - opts: { - checkValues: boolean; - cwd: string; - allowDuplicates?: boolean; - fix?: boolean; - json?: boolean; - ignore: string[]; - ignoreRegex: RegExp[]; - collect?: (entry: CompareJsonEntry) => void; - only?: Category[]; - showStats?: boolean; - strict?: boolean; - }, -) { + pairs: FilePair[], + opts: ComparisonOptions, +): Promise { let exitWithError = false; - const onlySet: Set | undefined = opts.only?.length - ? new Set(opts.only) - : undefined; - const run = (cat: Category) => !onlySet || onlySet.has(cat); + // For --only filtering + const run = createCategoryFilter(opts); + // Overall totals (for --show-stats summary) const totals: Record = { missing: 0, extra: 0, @@ -51,84 +136,40 @@ export async function compareMany( const exampleName = path.basename(examplePath); const entry: CompareJsonEntry = { env: envName, example: exampleName }; - if (!fs.existsSync(envPath) || !fs.existsSync(examplePath)) { - if (!opts.json) { - console.log(); - console.log(chalk.blue(`🔍 Comparing ${envName} ↔ ${exampleName}...`)); - console.log( - chalk.yellow('⚠️ Skipping: missing matching example file.'), - ); - console.log(); - } + const skipping = !fs.existsSync(envPath) || !fs.existsSync(examplePath); + + printHeader(envName, exampleName, opts.json ?? false, skipping); + + if (skipping) { + exitWithError = true; entry.skipped = { reason: 'missing file' }; opts.collect?.(entry); continue; } - if (!opts.json) { - console.log(); - console.log(chalk.blue(`🔍 Comparing ${envName} ↔ ${exampleName}...`)); - console.log(); - } - - // Git ignore hint (only when not JSON) - let gitignoreUnsafe = false; - if (run('gitignore')) { - warnIfEnvNotIgnored({ - cwd: opts.cwd, - envFile: envName, - log: (msg) => { - gitignoreUnsafe = true; - if (!opts.json) console.log(msg.replace(/^/gm, ' ')); - }, - }); - } - - // Duplicate detection (skip entirely if --only excludes it) - let dupsEnv: Array<{ key: string; count: number }> = []; - let dupsEx: Array<{ key: string; count: number }> = []; - if (!opts.allowDuplicates && run('duplicate')) { - dupsEnv = findDuplicateKeys(envPath).filter( - ({ key }) => - !opts.ignore.includes(key) && - !opts.ignoreRegex.some((rx) => rx.test(key)), - ); - dupsEx = findDuplicateKeys(examplePath).filter( - ({ key }) => - !opts.ignore.includes(key) && - !opts.ignoreRegex.some((rx) => rx.test(key)), - ); - } - - // Diff + empty - const currentFull = parseEnvFile(envPath); - const exampleFull = parseEnvFile(examplePath); - - const currentKeys = filterIgnoredKeys( - Object.keys(currentFull), - opts.ignore, - opts.ignoreRegex, - ); - const exampleKeys = filterIgnoredKeys( - Object.keys(exampleFull), - opts.ignore, - opts.ignoreRegex, - ); - - const current = Object.fromEntries( - currentKeys.map((k) => [k, currentFull[k] ?? '']), - ); - const example = Object.fromEntries( - exampleKeys.map((k) => [k, exampleFull[k] ?? '']), + // Parse and filter env files + const { current, example, currentKeys, exampleKeys } = parseAndFilter( + envPath, + examplePath, + opts, ); + // Run checks const diff = diffEnv(current, example, opts.checkValues); const emptyKeys = Object.entries(current) .filter(([, v]) => (v ?? '').trim() === '') .map(([k]) => k); - const filtered = { + // Find duplicates + const { dupsEnv, dupsEx } = findDuplicates(envPath, examplePath, opts, run); + + const gitignoreIssue = run('gitignore') + ? checkGitignoreStatus({ cwd: opts.cwd, envFile: envName }) + : null; + + // Collect filtered results + const filtered: Filtered = { missing: run('missing') ? diff.missing : [], extra: run('extra') ? diff.extra : [], empty: run('empty') ? emptyKeys : [], @@ -136,10 +177,10 @@ export async function compareMany( run('mismatch') && opts.checkValues ? diff.valueMismatches : [], duplicatesEnv: run('duplicate') ? dupsEnv : [], duplicatesEx: run('duplicate') ? dupsEx : [], - gitignoreUnsafe: run('gitignore') ? gitignoreUnsafe : false, + gitignoreIssue, }; - // --- Stats block for compare mode when --show-stats is active --- + // Print stats if requested if (opts.showStats && !opts.json) { const envCount = currentKeys.length; const exampleCount = exampleKeys.length; @@ -147,220 +188,93 @@ export async function compareMany( currentKeys.filter((k) => exampleKeys.includes(k)), ).size; - // Duplicate "occurrences beyond the first", summed across both files const duplicateCount = [...dupsEnv, ...dupsEx].reduce( (acc, { count }) => acc + Math.max(0, count - 1), 0, ); const valueMismatchCount = opts.checkValues - ? filtered.mismatches.length + ? (filtered.mismatches?.length ?? 0) : 0; - console.log(chalk.magenta('📊 Compare Statistics:')); - console.log(chalk.magenta.dim(` Keys in ${envName}: ${envCount}`)); - console.log( - chalk.magenta.dim(` Keys in ${exampleName}: ${exampleCount}`), - ); - console.log(chalk.magenta.dim(` Shared keys: ${sharedCount}`)); - console.log( - chalk.magenta.dim( - ` Missing (in ${envName}): ${filtered.missing.length}`, - ), - ); - console.log( - chalk.magenta.dim( - ` Extra (not in ${exampleName}): ${filtered.extra.length}`, - ), - ); - console.log( - chalk.magenta.dim(` Empty values: ${filtered.empty.length}`), - ); - console.log(chalk.magenta.dim(` Duplicate keys: ${duplicateCount}`)); - console.log( - chalk.magenta.dim(` Value mismatches: ${valueMismatchCount}`), + printStats( + envName, + exampleName, + { + envCount, + exampleCount, + sharedCount, + duplicateCount, + valueMismatchCount, + }, + filtered, + opts.json ?? false, + opts.showStats ?? true, ); - console.log(); } - const allOk = - filtered.missing.length === 0 && - filtered.extra.length === 0 && - filtered.empty.length === 0 && - filtered.duplicatesEnv.length === 0 && - filtered.duplicatesEx.length === 0 && - filtered.mismatches.length === 0; + // Check if all is OK + const allOk = isAllOk(filtered); if (allOk) { entry.ok = true; - if (!opts.json) { - console.log(chalk.green('✅ All keys match.')); - console.log(); - } + printSuccess(opts.json ?? false, 'compare'); opts.collect?.(entry); continue; } - // --- move duplicate logging AFTER stats --- - if (dupsEnv.length || dupsEx.length) { - entry.duplicates = {}; - } - if (dupsEnv.length) { - entry.duplicates!.env = dupsEnv; - if (!opts.json) { - console.log( - chalk.yellow( - `⚠️ Duplicate keys in ${envName} (last occurrence wins):`, - ), - ); - dupsEnv.forEach(({ key, count }) => - console.log(chalk.yellow(` - ${key} (${count} occurrences)`)), - ); - console.log(); - } - } - if (dupsEx.length) { - entry.duplicates!.example = dupsEx; - if (!opts.json) { - console.log( - chalk.yellow( - `⚠️ Duplicate keys in ${exampleName} (last occurrence wins):`, - ), - ); - dupsEx.forEach(({ key, count }) => - console.log(chalk.yellow(` - ${key} (${count} occurrences)`)), - ); - console.log(); - } - } + // Print duplicates + printDuplicates(envName, exampleName, dupsEnv, dupsEx, opts.json ?? false); - if (filtered.missing.length) { - entry.missing = filtered.missing; - exitWithError = true; - totals.missing += filtered.missing.length; - } - if (filtered.extra.length) { - entry.extra = filtered.extra; - exitWithError = true; - totals.extra += filtered.extra.length; - } - if (filtered.empty.length) { - entry.empty = filtered.empty; - exitWithError = true; - totals.empty += filtered.empty.length; - } - if (filtered.mismatches.length) { - entry.valueMismatches = filtered.mismatches; - totals.mismatch += filtered.mismatches.length; - exitWithError = true; - } - if (filtered.duplicatesEnv.length || filtered.duplicatesEx.length) { - totals.duplicate += - filtered.duplicatesEnv.length + filtered.duplicatesEx.length; - exitWithError = true; - } - if (filtered.gitignoreUnsafe) { - totals.gitignore += 1; + // Track errors and update totals + const shouldExit = updateTotals(filtered, totals, entry); + if (shouldExit) { exitWithError = true; } - if (!opts.json) { - if (filtered.missing.length && !opts.fix) { - console.log(chalk.red('❌ Missing keys:')); - filtered.missing.forEach((key) => console.log(chalk.red(` - ${key}`))); - console.log(); - } - if (filtered.extra.length) { - console.log(chalk.yellow('⚠️ Extra keys (not in example):')); - filtered.extra.forEach((key) => - console.log(chalk.yellow(` - ${key}`)), - ); - console.log(); - } - if (filtered.empty.length) { - console.log(chalk.yellow('⚠️ Empty values:')); - filtered.empty.forEach((key) => - console.log(chalk.yellow(` - ${key}`)), - ); - console.log(); - } - if (filtered.mismatches.length) { - console.log(chalk.yellow('⚠️ Value mismatches:')); - filtered.mismatches.forEach(({ key, expected, actual }) => - console.log( - chalk.yellow( - ` - ${key}: expected '${expected}', but got '${actual}'`, - ), - ), - ); - console.log(); - } - } + // Print all issues + printIssues(filtered, opts.json ?? false); - if (!opts.json && !opts.fix) { - if ( - filtered.missing.length || - filtered.duplicatesEnv.length || - filtered.duplicatesEx.length - ) { - console.log( - chalk.gray( - '💡 Tip: Run with `--fix` to automatically add missing keys and remove duplicates.', - ), - ); - console.log(); - } + if (filtered.gitignoreIssue && !opts.json) { + printGitignoreWarning({ + envFile: envName, + reason: filtered.gitignoreIssue.reason, + }); } + const hasGitignoreIssue: boolean = filtered.gitignoreIssue !== null; + + printFixTips( + filtered, + hasGitignoreIssue, + opts.json ?? false, + opts.fix ?? false, + ); + + // Apply auto-fix if requested if (opts.fix) { const { changed, result } = applyFixes({ envPath, examplePath, missingKeys: filtered.missing, duplicateKeys: dupsEnv.map((d) => d.key), + ensureGitignore: hasGitignoreIssue, }); - if (!opts.json) { - if (changed) { - console.log(chalk.green('✅ Auto-fix applied:')); - if (result.removedDuplicates.length) { - console.log( - chalk.green( - ` - Removed ${result.removedDuplicates.length} duplicate keys from ${envName}: ${result.removedDuplicates.join(', ')}`, - ), - ); - } - if (result.addedEnv.length) { - console.log( - chalk.green( - ` - Added ${result.addedEnv.length} missing keys to ${envName}: ${result.addedEnv.join(', ')}`, - ), - ); - } - if (result.addedExample.length) { - console.log( - chalk.green( - ` - Synced ${result.addedExample.length} keys to ${exampleName}: ${result.addedExample.join(', ')}`, - ), - ); - } - } else { - console.log(chalk.green('✅ Auto-fix applied: no changes needed.')); - } - console.log(); - } + printAutoFix( + changed, + result, + envName, + exampleName, + opts.json ?? false, + hasGitignoreIssue, + ); } opts.collect?.(entry); - const warningsExist = - filtered.extra.length > 0 || - filtered.empty.length > 0 || - filtered.duplicatesEnv.length > 0 || - filtered.duplicatesEx.length > 0 || - filtered.mismatches.length > 0 || - filtered.gitignoreUnsafe; - - if (opts.strict && warningsExist) { + + // In strict mode, any issue (not just errors) causes exit with error + if (opts.strict && !allOk) { exitWithError = true; } } diff --git a/src/commands/scanUsage.ts b/src/commands/scanUsage.ts index 49249ac5..f67df28d 100644 --- a/src/commands/scanUsage.ts +++ b/src/commands/scanUsage.ts @@ -1,17 +1,50 @@ -import chalk from 'chalk'; -import fs from 'fs'; -import path from 'path'; -import { parseEnvFile } from '../core/parseEnv.js'; import { scanCodebase } from '../services/codeBaseScanner.js'; -import type { ScanUsageOptions, EnvUsage } from '../config/types.js'; -import { filterIgnoredKeys } from '../core/filterIgnoredKeys.js'; -import { resolveFromCwd } from '../core/helpers/resolveFromCwd.js'; +import type { + ScanUsageOptions, + EnvUsage, + ScanResult, +} from '../config/types.js'; import { determineComparisonFile } from '../core/determineComparisonFile.js'; import { outputToConsole } from '../services/scanOutputToConsole.js'; import { createJsonOutput } from '../core/scanJsonOutput.js'; -import { findDuplicateKeys } from '../services/duplicates.js'; -import { compareWithEnvFiles } from '../core/compareScan.js'; -import { applyFixes } from '../core/fixEnv.js'; +import { printMissingExample } from '../ui/scan/printMissingExample.js'; +import { processComparisonFile } from '../core/processComparisonFile.js'; +import { printComparisonError } from '../ui/scan/printComparisonError.js'; + +/** + * Filters out commented usages from the list. + * Skipping comments: + * // process.env.API_URL + * # process.env.API_URL + * /* process.env.API_URL + * * process.env.API_URL + * @param usages - List of environment variable usages + * @returns Filtered list of environment variable usages + */ +function skipCommentedUsages(usages: EnvUsage[]): EnvUsage[] { + return usages.filter( + (u) => u.context && !/^\s*(\/\/|#|\/\*|\*)/.test(u.context.trim()), + ); +} + +/** + * Recalculates statistics for a scan result after filtering usages. + * @param scanResult The current scan result + * @returns Updated scanResult with recalculated stats + */ +function calculateStats(scanResult: ScanResult): ScanResult { + const uniqueVariables = new Set( + scanResult.used.map((u: EnvUsage) => u.variable), + ).size; + + scanResult.stats = { + filesScanned: scanResult.stats.filesScanned, + totalUsages: scanResult.used.length, + uniqueVariables, + }; + + return scanResult; +} /** * Scans codebase for environment variable usage and compares with .env file @@ -28,45 +61,18 @@ import { applyFixes } from '../core/fixEnv.js'; export async function scanUsage( opts: ScanUsageOptions, ): Promise<{ exitWithError: boolean }> { - if (!opts.json) { - console.log(); - console.log( - chalk.blue('🔍 Scanning codebase for environment variable usage...'), - ); - console.log(); - } - // Scan the codebase let scanResult = await scanCodebase(opts); - scanResult.used = scanResult.used.filter( - (u: EnvUsage) => u.context && !/^\s*(\/\/|#)/.test(u.context.trim()), - ); + // Filter out commented usages + scanResult.used = skipCommentedUsages(scanResult.used); - // Recalculate stats after filtering out commented usages - const uniqueVariables = new Set(scanResult.used.map((u) => u.variable)).size; - scanResult.stats = { - filesScanned: scanResult.stats.filesScanned, // Keep original files scanned count - totalUsages: scanResult.used.length, - uniqueVariables: uniqueVariables, - }; + // Recalculate stats after filtering + calculateStats(scanResult); - // If user explicitly passed --example but the file doesn't exist: - if (opts.examplePath) { - const exampleAbs = resolveFromCwd(opts.cwd, opts.examplePath); - const missing = !fs.existsSync(exampleAbs); - - if (missing) { - const msg = `❌ Missing specified example file: ${opts.examplePath}`; - if (opts.isCiMode) { - // IMPORTANT: stdout (console.log), not stderr, to satisfy the test - console.log(chalk.red(msg)); - return { exitWithError: true }; - } else if (!opts.json) { - console.log(chalk.yellow(msg.replace('❌', '⚠️'))); - } - // Non-CI: continue without comparison - } + // If user explicitly passed --example flag, but the file doesn't exist: + if (printMissingExample(opts)) { + return { exitWithError: true }; } // Determine which file to compare against @@ -81,135 +87,35 @@ export async function scanUsage( let fixApplied = false; let fixedKeys: string[] = []; let removedDuplicates: string[] = []; + let gitignoreUpdated = false; + // If comparing against a file, process it + // fx: if the scan is comparing against .env.example, it will check for missing keys there if (compareFile) { - try { - const envFull = parseEnvFile(compareFile.path); - const envKeys = filterIgnoredKeys( - Object.keys(envFull), - opts.ignore, - opts.ignoreRegex, - ); - envVariables = Object.fromEntries(envKeys.map((k) => [k, envFull[k]])); - scanResult = compareWithEnvFiles(scanResult, envVariables); - comparedAgainst = compareFile.name; - - // Check for duplicates in the env file - if (!opts.allowDuplicates) { - dupsEnv = findDuplicateKeys(compareFile.path).filter( - ({ key }) => - !opts.ignore.includes(key) && - !opts.ignoreRegex.some((rx) => rx.test(key)), - ); - - // Also check for duplicates in example file if it exists AND is different from compareFile - if (opts.examplePath) { - const examplePath = resolveFromCwd(opts.cwd, opts.examplePath); - // Only check example file if it exists and is NOT the same as the comparison file - if (fs.existsSync(examplePath) && examplePath !== compareFile.path) { - dupsExample = findDuplicateKeys(examplePath).filter( - ({ key }) => - !opts.ignore.includes(key) && - !opts.ignoreRegex.some((rx) => rx.test(key)), - ); - } - } - - duplicatesFound = dupsEnv.length > 0 || dupsExample.length > 0; + const result = processComparisonFile(scanResult, compareFile, opts); - // Apply duplicate fixes if --fix is enabled (but don't show message yet) - if (opts.fix && (dupsEnv.length > 0 || dupsExample.length > 0)) { - const { changed, result } = applyFixes({ - envPath: compareFile.path, - examplePath: opts.examplePath - ? resolveFromCwd(opts.cwd, opts.examplePath) - : '', - missingKeys: [], - duplicateKeys: dupsEnv.map((d) => d.key), - }); - - if (changed) { - fixApplied = true; - removedDuplicates = result.removedDuplicates; - // Clear duplicates after fix - duplicatesFound = false; - dupsEnv = []; - dupsExample = []; - } - } - - // Add to scan result for both JSON and console output (only if not fixed) - if ( - (dupsEnv.length > 0 || dupsExample.length > 0) && - (!opts.fix || !fixApplied) - ) { - if (!scanResult.duplicates) scanResult.duplicates = {}; - if (dupsEnv.length > 0) scanResult.duplicates.env = dupsEnv; - if (dupsExample.length > 0) - scanResult.duplicates.example = dupsExample; - } - } - } catch (error) { - const errorMessage = `⚠️ Could not read ${compareFile.name}: ${compareFile.path} - ${error}`; - - if (opts.isCiMode) { - // In CI mode, exit with error if file doesn't exist - console.log(chalk.red(`❌ ${errorMessage}`)); - return { exitWithError: true }; - } - - if (!opts.json) { - console.log(chalk.yellow(errorMessage)); - } - } - } - - // Apply missing keys fix if --fix is enabled (but don't show message yet) - if (opts.fix && compareFile) { - const missingKeys = scanResult.missing; - - if (missingKeys.length > 0) { - const envFilePath = compareFile.path; - const exampleFilePath = opts.examplePath - ? resolveFromCwd(opts.cwd, opts.examplePath) - : null; - - // Append missing keys to .env - const content = fs.readFileSync(envFilePath, 'utf-8'); - const newContent = - content + - (content.endsWith('\n') ? '' : '\n') + - missingKeys.map((k) => `${k}=`).join('\n') + - '\n'; - fs.writeFileSync(envFilePath, newContent); - - // Append to .env.example if it exists - if (exampleFilePath && fs.existsSync(exampleFilePath)) { - const exContent = fs.readFileSync(exampleFilePath, 'utf-8'); - const existingExKeys = new Set( - exContent - .split('\n') - .map((l) => l.trim().split('=')[0]) - .filter(Boolean), - ); - const newKeys = missingKeys.filter((k) => !existingExKeys.has(k)); - if (newKeys.length) { - const newExContent = - exContent + - (exContent.endsWith('\n') ? '' : '\n') + - newKeys.join('\n') + - '\n'; - fs.writeFileSync(exampleFilePath, newExContent); - } - } - - fixApplied = true; - fixedKeys = missingKeys; - scanResult.missing = []; + if (result.error) { + const { exit } = printComparisonError( + result.error.message, + result.error.shouldExit, + opts.json ?? false, + ); + if (exit) return { exitWithError: true }; + } else { + scanResult = result.scanResult; + envVariables = result.envVariables; + comparedAgainst = result.comparedAgainst; + duplicatesFound = result.duplicatesFound; + dupsEnv = result.dupsEnv; + dupsExample = result.dupsExample; + fixApplied = result.fixApplied; + removedDuplicates = result.removedDuplicates; + fixedKeys = result.addedEnv; + gitignoreUpdated = result.gitignoreUpdated; } } - // Prepare JSON output + // JSON output if (opts.json) { const jsonOutput = createJsonOutput( scanResult, @@ -233,64 +139,12 @@ export async function scanUsage( } // Console output - const result = outputToConsole(scanResult, opts, comparedAgainst); - - // Show consolidated fix message at the bottom (after all other output) - if (opts.fix && !opts.json) { - if (fixApplied) { - console.log(chalk.green('✅ Auto-fix applied:')); - - // Show removed duplicates - if (removedDuplicates.length > 0) { - console.log( - chalk.green( - ` - Removed ${removedDuplicates.length} duplicate keys from ${comparedAgainst}: ${removedDuplicates.join(', ')}`, - ), - ); - } - - // Show added missing keys - if (fixedKeys.length > 0) { - console.log( - chalk.green( - ` - Added ${fixedKeys.length} missing keys to ${comparedAgainst}: ${fixedKeys.join(', ')}`, - ), - ); - - if (opts.examplePath) { - console.log( - chalk.green( - ` - Synced ${fixedKeys.length} keys to ${path.basename(opts.examplePath)}`, - ), - ); - } - } - - console.log(); - } else { - console.log(chalk.green('✅ Auto-fix applied: no changes needed.')); - console.log(); - } - } - - if (!opts.json && !opts.fix) { - if (scanResult.missing.length > 0 && duplicatesFound) { - console.log( - chalk.gray( - '💡 Tip: Run with `--fix` to add missing keys and remove duplicates', - ), - ); - console.log(); - } else if (scanResult.missing.length > 0) { - console.log(chalk.gray('💡 Tip: Run with `--fix` to add missing keys')); - console.log(); - } else if (duplicatesFound) { - console.log( - chalk.gray('💡 Tip: Run with `--fix` to remove duplicate keys'), - ); - console.log(); - } - } + const result = outputToConsole(scanResult, opts, comparedAgainst, { + fixApplied, + removedDuplicates, + addedEnv: fixedKeys, + gitignoreUpdated, + }); return { exitWithError: result.exitWithError || duplicatesFound }; } diff --git a/src/config/options.ts b/src/config/options.ts index e3ace791..c819dd18 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -1,4 +1,3 @@ -import chalk from 'chalk'; import path from 'path'; import { ALLOWED_CATEGORIES, @@ -6,6 +5,11 @@ import { type Options, type RawOptions, } from './types.js'; +import { + printInvalidCategory, + printInvalidRegex, + printCiYesWarning, +} from '../ui/shared/printOptionErrors.js'; /** * Parses a comma-separated list of strings into an array of strings. @@ -26,73 +30,74 @@ function parseList(val?: string | string[]): string[] { * @param flagName - The name of the flag being parsed (for error messages). * @returns An array of categories. */ -function parseCategories(val?: string | string[], flagName = ''): Category[] { +function parseCategories(val?: string | string[], flagName = '--only'): Category[] { const raw = parseList(val); const bad = raw.filter((c) => !ALLOWED_CATEGORIES.includes(c as Category)); if (bad.length) { - console.error( - chalk.red( - `❌ Error: invalid ${flagName} value(s): ${bad.join(', ')}. Allowed: ${ALLOWED_CATEGORIES.join(', ')}`, - ), - ); - process.exit(1); + printInvalidCategory(flagName, bad, ALLOWED_CATEGORIES); } return raw as Category[]; } +/** + * Parses regex patterns safely, exiting on invalid syntax. + */ +function parseRegexList(val?: string | string[]): RegExp[] { + const regexList: RegExp[] = []; + for (const pattern of parseList(val)) { + try { + regexList.push(new RegExp(pattern)); + } catch { + printInvalidRegex(pattern); + } + } + return regexList; +} + +/** + * Converts flag value to boolean. + */ +function toBool(value: unknown): boolean { + return value === true || value === 'true'; +} + /** * Normalizes the raw options by providing default values and parsing specific fields. * @param raw - The raw options to normalize. * @returns The normalized options. */ export function normalizeOptions(raw: RawOptions): Options { - const checkValues = raw.checkValues ?? false; - const isCiMode = Boolean(raw.ci); - const isYesMode = Boolean(raw.yes); - const allowDuplicates = Boolean(raw.allowDuplicates); - const fix = Boolean(raw.fix); - const json = Boolean(raw.json); - const onlyParsed = parseCategories(raw.only, '--only'); - const only = onlyParsed.length ? onlyParsed : []; - const noColor = Boolean(raw.noColor); - const compare = Boolean(raw.compare); + const checkValues = toBool(raw.checkValues); + const isCiMode = toBool(raw.ci); + const isYesMode = toBool(raw.yes); + const allowDuplicates = toBool(raw.allowDuplicates); + const fix = toBool(raw.fix); + const json = toBool(raw.json); + const noColor = toBool(raw.noColor); + const compare = toBool(raw.compare); + const strict = toBool(raw.strict); const scanUsage = raw.scanUsage ?? !compare; + + const showUnused = raw.showUnused !== false; + const showStats = raw.showStats !== false; + const secrets = raw.secrets !== false; + + const only = parseCategories(raw.only); + const ignore = parseList(raw.ignore); + const ignoreRegex = parseRegexList(raw.ignoreRegex); const includeFiles = parseList(raw.includeFiles); const excludeFiles = parseList(raw.excludeFiles); - const showUnused = raw.showUnused === false ? false : true; - const showStats = raw.showStats === false ? false : true; const files = parseList(raw.files); - const secrets = raw.secrets === false ? false : true; - const strict = Boolean(raw.strict); - const ignore = parseList(raw.ignore); - const ignoreRegex: RegExp[] = []; - for (const pattern of parseList(raw.ignoreRegex)) { - try { - ignoreRegex.push(new RegExp(pattern)); - } catch { - console.error( - chalk.red(`❌ Error: invalid --ignore-regex pattern: ${pattern}`), - ); - process.exit(1); - } - } + const cwd = process.cwd(); + const envFlag = typeof raw.env === 'string' ? path.resolve(cwd, raw.env) : undefined; + const exampleFlag = + typeof raw.example === 'string' ? path.resolve(cwd, raw.example) : undefined; if (isCiMode && isYesMode) { - console.log( - chalk.yellow('⚠️ Both --ci and --yes provided; proceeding with --yes.'), - ); + printCiYesWarning(); } - const cwd = process.cwd(); - - const envFlag = - typeof raw.env === 'string' ? path.resolve(cwd, raw.env) : undefined; - const exampleFlag = - typeof raw.example === 'string' - ? path.resolve(cwd, raw.example) - : undefined; - return { checkValues, isCiMode, diff --git a/src/config/types.ts b/src/config/types.ts index abece438..c0ee5eaa 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -188,3 +188,51 @@ export interface ScanJsonEntry { export interface VariableUsages { [variable: string]: EnvUsage[]; } + +export interface ComparisonOptions { + checkValues: boolean; + cwd: string; + allowDuplicates?: boolean; + fix?: boolean; + json?: boolean; + ignore: string[]; + ignoreRegex: RegExp[]; + collect?: (entry: CompareJsonEntry) => void; + only?: Category[]; + showStats?: boolean; + strict?: boolean; +} + +export interface FilePair { + envName: string; + envPath: string; + examplePath: string; +} + +export interface ComparisonResult { + exitWithError: boolean; +} + +export type PairContext = { + envName: string; + envPath: string; + exampleName: string; + examplePath: string; + exists: { env: boolean; example: boolean }; + currentFull?: Record; + exampleFull?: Record; + currentKeys?: string[]; + exampleKeys?: string[]; + current?: Record; + example?: Record; +}; + +export type Filtered = { + missing: string[]; + extra?: string[]; + empty?: string[]; + mismatches?: Array<{ key: string; expected: string; actual: string }>; + duplicatesEnv: Array<{ key: string; count: number }>; + duplicatesEx: Array<{ key: string; count: number }>; + gitignoreIssue: { reason: 'no-gitignore' | 'not-ignored' } | null; +}; diff --git a/src/core/fixEnv.ts b/src/core/fixEnv.ts index f599b07c..fae76a54 100644 --- a/src/core/fixEnv.ts +++ b/src/core/fixEnv.ts @@ -1,28 +1,51 @@ import fs from 'fs'; +import path from 'path'; +import { isEnvIgnoredByGit, isGitRepo, findGitRoot } from '../services/git.js'; -/** - * Applies fixes to the .env and .env.example files based on the detected issues. - * @param envPath - The path to the .env file. - * @param examplePath - The path to the .env.example file. - * @param missingKeys - The list of missing keys to add. - * @param duplicateKeys - The list of duplicate keys to remove. - * @returns An object indicating whether changes were made and details of the changes. - */ -export function applyFixes({ - envPath, - examplePath, - missingKeys, - duplicateKeys, -}: { +export type ApplyFixesOptions = { envPath: string; examplePath: string; missingKeys: string[]; duplicateKeys: string[]; -}) { - const result = { - removedDuplicates: [] as string[], - addedEnv: [] as string[], - addedExample: [] as string[], + ensureGitignore?: boolean; +}; + +export type FixResult = { + removedDuplicates: string[]; + addedEnv: string[]; + addedExample: string[]; + gitignoreUpdated: boolean; +}; + +/** + * Applies fixes to the .env and .env.example files based on the detected issues. + * + * This function will: + * - Remove duplicate keys from .env (keeping the last occurrence) + * - Add missing keys to .env with empty values + * - Add missing keys to .env.example (if not already present) + * - Ensure .env is ignored in .gitignore (if in a git repo and ensureGitignore is true) + * + * @param options - Fix options including file paths and keys to fix + * @returns An object indicating whether changes were made and details of the changes + */ +export function applyFixes(options: ApplyFixesOptions): { + changed: boolean; + result: FixResult; +} { + const { + envPath, + examplePath, + missingKeys, + duplicateKeys, + ensureGitignore, + } = options; + + const result: FixResult = { + removedDuplicates: [], + addedEnv: [], + addedExample: [], + gitignoreUpdated: false, }; // --- Remove duplicates --- @@ -30,21 +53,25 @@ export function applyFixes({ const lines = fs.readFileSync(envPath, 'utf-8').split('\n'); const seen = new Set(); const newLines: string[] = []; + + // Process from bottom to top, keeping last occurrence for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; if (line === undefined) continue; + const match = line.match(/^\s*([\w.-]+)\s*=/); if (match) { const key = match[1] || ''; if (duplicateKeys.includes(key)) { - if (seen.has(key)) continue; // skip duplicate + if (seen.has(key)) continue; // Skip duplicate seen.add(key); } } newLines.unshift(line); } + fs.writeFileSync(envPath, newLines.join('\n')); - result.removedDuplicates = duplicateKeys; // save all dupe keys + result.removedDuplicates = duplicateKeys; } // --- Add missing keys to .env --- @@ -56,7 +83,7 @@ export function applyFixes({ missingKeys.map((k) => `${k}=`).join('\n') + '\n'; fs.writeFileSync(envPath, newContent); - result.addedEnv = missingKeys; // save all missing keys + result.addedEnv = missingKeys; } // --- Add missing keys to .env.example --- @@ -69,6 +96,7 @@ export function applyFixes({ .filter(Boolean), ); const newExampleKeys = missingKeys.filter((k) => !existingExKeys.has(k)); + if (newExampleKeys.length) { const newExContent = exContent + @@ -76,14 +104,75 @@ export function applyFixes({ newExampleKeys.join('\n') + '\n'; fs.writeFileSync(examplePath, newExContent); - result.addedExample = newExampleKeys; // save all keys actually added + result.addedExample = newExampleKeys; } } + // --- Ensure .env is ignored in .gitignore --- + if (ensureGitignore) { + result.gitignoreUpdated = updateGitignoreForEnv(envPath); + } + const changed = result.removedDuplicates.length > 0 || result.addedEnv.length > 0 || - result.addedExample.length > 0; + result.addedExample.length > 0 || + result.gitignoreUpdated; return { changed, result }; } + +/** + * Ensures .env patterns are present in .gitignore at the git repository root. + * This is a best-effort operation and will not throw errors. + * + * @param envPath - Path to the .env file to check gitignore for + * @returns true if .gitignore was updated, false otherwise + */ +function updateGitignoreForEnv(envPath: string): boolean { + try { + const startDir = path.dirname(envPath); + const gitRoot = findGitRoot(startDir); + + if (!gitRoot || !isGitRepo(gitRoot)) { + return false; + } + + const gitignorePath = path.join(gitRoot, '.gitignore'); + const envFileName = path.basename(envPath); + const ignored = isEnvIgnoredByGit({ cwd: gitRoot, envFile: envFileName }); + + // Already properly ignored + if (ignored === true) { + return false; + } + + // Need to add patterns + const patterns = ['.env', '.env.*']; + + if (fs.existsSync(gitignorePath)) { + const current = fs.readFileSync(gitignorePath, 'utf8'); + const existingLines = current.split(/\r?\n/).map((l) => l.trim()); + + const missingPatterns = patterns.filter( + (pattern) => !existingLines.includes(pattern) + ); + + if (missingPatterns.length) { + const toAppend = + `${current.endsWith('\n') ? '' : '\n'}${missingPatterns.join('\n')}\n`; + fs.appendFileSync(gitignorePath, toAppend); + return true; + } + } else { + // Create new .gitignore + fs.writeFileSync(gitignorePath, patterns.join('\n') + '\n'); + return true; + } + + return false; + } catch { + // Non-blocking: ignore errors + return false; + } +} diff --git a/src/core/helpers/isAllOk.ts b/src/core/helpers/isAllOk.ts new file mode 100644 index 00000000..b592312e --- /dev/null +++ b/src/core/helpers/isAllOk.ts @@ -0,0 +1,18 @@ +import type { Filtered } from '../../config/types.js'; + +/** + * Checks if all filtered comparison results are okay (i.e., no issues found). + * @param filtered - The filtered comparison results. + * @returns True if all checks pass, false otherwise. + */ +export function isAllOk(filtered: Filtered): boolean { + return ( + filtered.missing.length === 0 && + filtered.extra?.length === 0 && + filtered.empty?.length === 0 && + filtered.duplicatesEnv.length === 0 && + filtered.duplicatesEx.length === 0 && + filtered.mismatches?.length === 0 && + !filtered.gitignoreIssue + ); +} diff --git a/src/core/helpers/updateTotals.ts b/src/core/helpers/updateTotals.ts new file mode 100644 index 00000000..d6b4d2a3 --- /dev/null +++ b/src/core/helpers/updateTotals.ts @@ -0,0 +1,59 @@ +import type { CompareJsonEntry } from '../../config/types.js'; +import type { Filtered } from '../../config/types.js'; + +export interface Totals { + missing: number; + extra: number; + empty: number; + mismatch: number; + duplicate: number; + gitignore: number; +}; + +/** + * Update totals and entry fields based on filtered issues. + * Returns true if any issue requires exitWithError. + * @param filtered filtered issues + * @param totals overall totals + * @param entry current entry to update + * @returns exitWithError + */ +export function updateTotals( + filtered: Filtered, + totals: Totals, + entry: CompareJsonEntry, +): boolean { + let exitWithError = false; + + if (filtered.missing.length) { + entry.missing = filtered.missing; + totals.missing += filtered.missing.length; + exitWithError = true; + } + + if (filtered.extra?.length) { + entry.extra = filtered.extra; + totals.extra += filtered.extra.length; + } + + if (filtered.empty?.length) { + entry.empty = filtered.empty; + totals.empty += filtered.empty.length; + } + + if (filtered.mismatches?.length) { + entry.valueMismatches = filtered.mismatches; + totals.mismatch += filtered.mismatches.length; + } + + if (filtered.duplicatesEnv.length || filtered.duplicatesEx.length) { + totals.duplicate += + filtered.duplicatesEnv.length + filtered.duplicatesEx.length; + } + + if (filtered.gitignoreIssue) { + totals.gitignore += 1; + } + + return exitWithError; +} diff --git a/src/core/processComparisonFile.ts b/src/core/processComparisonFile.ts new file mode 100644 index 00000000..0fb058d3 --- /dev/null +++ b/src/core/processComparisonFile.ts @@ -0,0 +1,166 @@ +import fs from 'fs'; +import { parseEnvFile } from './parseEnv.js'; +import { filterIgnoredKeys } from './filterIgnoredKeys.js'; +import { compareWithEnvFiles } from './compareScan.js'; +import { findDuplicateKeys } from '../services/duplicates.js'; +import { applyFixes } from './fixEnv.js'; +import { resolveFromCwd } from './helpers/resolveFromCwd.js'; +import type { ScanUsageOptions, ScanResult } from '../config/types.js'; + +export interface ProcessComparisonResult { + scanResult: ScanResult; + envVariables: Record; + comparedAgainst: string; + duplicatesFound: boolean; + dupsEnv: Array<{ key: string; count: number }>; + dupsExample: Array<{ key: string; count: number }>; + fixApplied: boolean; + removedDuplicates: string[]; + addedEnv: string[]; + addedExample: string[]; + gitignoreUpdated: boolean; + error?: { message: string; shouldExit: boolean }; +} + +/** + * Process comparison file: parse env, check duplicates, check missing keys, apply fixes + * @param scanResult - Current scan result + * @param compareFile - File to compare against + * @param opts - Scan options + * @returns Processed comparison result + */ +export function processComparisonFile( + scanResult: ScanResult, + compareFile: { path: string; name: string }, + opts: ScanUsageOptions, +): ProcessComparisonResult { + let envVariables: Record = {}; + let comparedAgainst = ''; + let duplicatesFound = false; + let dupsEnv: Array<{ key: string; count: number }> = []; + let dupsExample: Array<{ key: string; count: number }> = []; + let fixApplied = false; + let removedDuplicates: string[] = []; + let addedEnv: string[] = []; + let addedExample: string[] = []; + let gitignoreUpdated = false; + + try { + // Parse and filter env file + const envFull = parseEnvFile(compareFile.path); + const envKeys = filterIgnoredKeys( + Object.keys(envFull), + opts.ignore, + opts.ignoreRegex, + ); + envVariables = Object.fromEntries(envKeys.map((k) => [k, envFull[k]])); + scanResult = compareWithEnvFiles(scanResult, envVariables); + comparedAgainst = compareFile.name; + + // Find duplicates + if (!opts.allowDuplicates) { + const duplicateResults = checkDuplicates(compareFile, opts); + dupsEnv = duplicateResults.dupsEnv; + dupsExample = duplicateResults.dupsExample; + duplicatesFound = dupsEnv.length > 0 || dupsExample.length > 0; + } + + // Apply fixes (both duplicates + missing keys + gitignore) + if (opts.fix && (duplicatesFound || scanResult.missing.length > 0 || true)) { + const { changed, result } = applyFixes({ + envPath: compareFile.path, + examplePath: opts.examplePath + ? resolveFromCwd(opts.cwd, opts.examplePath) + : '', + missingKeys: scanResult.missing, + duplicateKeys: dupsEnv.map((d) => d.key), + ensureGitignore: true, + }); + + if (changed) { + fixApplied = true; + removedDuplicates = result.removedDuplicates; + addedEnv = result.addedEnv; + addedExample = result.addedExample; + gitignoreUpdated = result.gitignoreUpdated; + + scanResult.missing = []; + dupsEnv = []; + dupsExample = []; + duplicatesFound = false; + } + } + + // Keep duplicates for output if not fixed + if (duplicatesFound && (!opts.fix || !fixApplied)) { + if (!scanResult.duplicates) scanResult.duplicates = {}; + if (dupsEnv.length > 0) scanResult.duplicates.env = dupsEnv; + if (dupsExample.length > 0) scanResult.duplicates.example = dupsExample; + } + } catch (error) { + const errorMessage = `Could not read ${compareFile.name}: ${compareFile.path} - ${error}`; + return { + scanResult, + envVariables, + comparedAgainst, + duplicatesFound, + dupsEnv, + dupsExample, + fixApplied, + removedDuplicates, + addedEnv, + addedExample, + gitignoreUpdated, + error: { + message: errorMessage, + shouldExit: opts.isCiMode ?? false, + }, + }; + } + + return { + scanResult, + envVariables, + comparedAgainst, + duplicatesFound, + dupsEnv, + dupsExample, + fixApplied, + removedDuplicates, + addedEnv, + addedExample, + gitignoreUpdated, + }; +} + +/** + * Check for duplicate keys in env and example files + */ +function checkDuplicates( + compareFile: { path: string; name: string }, + opts: ScanUsageOptions, +): { + dupsEnv: Array<{ key: string; count: number }>; + dupsExample: Array<{ key: string; count: number }>; +} { + const dupsEnv = findDuplicateKeys(compareFile.path).filter( + ({ key }) => + !opts.ignore.includes(key) && + !opts.ignoreRegex.some((rx) => rx.test(key)), + ); + + let dupsExample: Array<{ key: string; count: number }> = []; + + if (opts.examplePath) { + const examplePath = resolveFromCwd(opts.cwd, opts.examplePath); + if (fs.existsSync(examplePath) && examplePath !== compareFile.path) { + dupsExample = findDuplicateKeys(examplePath).filter( + ({ key }) => + !opts.ignore.includes(key) && + !opts.ignoreRegex.some((rx) => rx.test(key)), + ); + } + } + + return { dupsEnv, dupsExample }; +} diff --git a/src/core/secretDetectors.ts b/src/core/secretDetectors.ts index a2f9f159..cd000130 100644 --- a/src/core/secretDetectors.ts +++ b/src/core/secretDetectors.ts @@ -43,6 +43,7 @@ const HARMLESS_URLS = [ /** * Checks if a line has an ignore comment + * fx: // dotenv-diff-ignore or /* dotenv-diff-ignore * @param line - The line to check * @returns True if the line should be ignored */ diff --git a/src/services/ensureFilesOrPrompt.ts b/src/services/ensureFilesOrPrompt.ts index d17d3602..164f6f61 100644 --- a/src/services/ensureFilesOrPrompt.ts +++ b/src/services/ensureFilesOrPrompt.ts @@ -1,8 +1,8 @@ import fs from 'fs'; import path from 'path'; -import chalk from 'chalk'; import { confirmYesNo } from '../ui/prompts.js'; import { warnIfEnvNotIgnored } from './git.js'; +import { printPrompt } from '../ui/compare/printPrompt.js'; /** * Ensures that the necessary .env files exist or prompts the user to create them. @@ -26,6 +26,7 @@ export async function ensureFilesOrPrompt(args: { isYesMode, isCiMode, } = args; + const envPath = path.resolve(cwd, primaryEnv); const examplePath = path.resolve(cwd, primaryExample); const envExists = fs.existsSync(envPath); @@ -35,11 +36,7 @@ export async function ensureFilesOrPrompt(args: { if (!envExists && !exampleExists) { const hasAnyEnv = fs.readdirSync(cwd).some((f) => f.startsWith('.env')); if (!hasAnyEnv) { - console.log( - chalk.yellow( - '⚠️ No .env* or .env.example file found. Skipping comparison.', - ), - ); + printPrompt.noEnvFound(); return { didCreate: false, shouldExit: true, exitCode: 0 }; } } @@ -47,48 +44,45 @@ export async function ensureFilesOrPrompt(args: { // Case 2: missing .env but has .env.example if (!envExists && exampleExists) { if (!alreadyWarnedMissingEnv) { - console.log(); - console.log(chalk.yellow(`📄 ${path.basename(envPath)} file not found.`)); + printPrompt.missingEnv(envPath); } - let createEnv = isYesMode + + const createEnv = isYesMode ? true : isCiMode - ? false - : await confirmYesNo( - `❓ Do you want to create a new ${path.basename(envPath)} file from ${path.basename(examplePath)}?`, - { isCiMode, isYesMode }, - ); + ? false + : await confirmYesNo( + `❓ Do you want to create a new ${path.basename(envPath)} file from ${path.basename(examplePath)}?`, + { isCiMode, isYesMode }, + ); if (!createEnv) { - console.log(chalk.gray('🚫 Skipping .env creation.')); + printPrompt.skipCreation('.env'); return { didCreate: false, shouldExit: true, exitCode: 0 }; } + const exampleContent = fs.readFileSync(examplePath, 'utf-8'); fs.writeFileSync(envPath, exampleContent); - console.log( - chalk.green( - `✅ ${path.basename(envPath)} file created successfully from ${path.basename(examplePath)}.\n`, - ), - ); + printPrompt.envCreated(envPath, examplePath); + warnIfEnvNotIgnored({ envFile: path.basename(envPath) }); } // Case 3: has .env but is missing .env.example if (envExists && !exampleExists) { - console.log( - chalk.yellow(`📄 ${path.basename(examplePath)} file not found.`), - ); - let createExample = isYesMode + printPrompt.missingEnv(examplePath); + + const createExample = isYesMode ? true : isCiMode - ? false - : await confirmYesNo( - `❓ Do you want to create a new ${path.basename(examplePath)} file from ${path.basename(envPath)}?`, - { isCiMode, isYesMode }, - ); + ? false + : await confirmYesNo( + `❓ Do you want to create a new ${path.basename(examplePath)} file from ${path.basename(envPath)}?`, + { isCiMode, isYesMode }, + ); if (!createExample) { - console.log(chalk.gray('🚫 Skipping .env.example creation.')); + printPrompt.skipCreation('.env.example'); return { didCreate: false, shouldExit: true, exitCode: 0 }; } @@ -104,11 +98,7 @@ export async function ensureFilesOrPrompt(args: { .join('\n'); fs.writeFileSync(examplePath, envContent); - console.log( - chalk.green( - `✅ ${path.basename(examplePath)} file created successfully from ${path.basename(envPath)}.\n`, - ), - ); + printPrompt.exampleCreated(examplePath, envPath); } return { didCreate: true, shouldExit: false, exitCode: 0 }; diff --git a/src/services/git.ts b/src/services/git.ts index 1695f71b..af20f6ce 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import chalk from 'chalk'; +import { printGitignoreWarning } from '../ui/shared/printGitignore.js'; export type GitignoreCheckOptions = { /** Project root directory (default: process.cwd()) */ @@ -86,26 +86,66 @@ export function warnIfEnvNotIgnored(options: GitignoreCheckOptions = {}): void { const envPath = path.resolve(cwd, envFile); if (!fs.existsSync(envPath)) return; // No .env file → nothing to warn about - if (!isGitRepo(cwd)) return; // Not a git repo → skip const gitignorePath = path.resolve(cwd, '.gitignore'); if (!fs.existsSync(gitignorePath)) { - log( - chalk.yellow( - `⚠️ No .gitignore found – your ${envFile} may be committed.\n Add:\n ${envFile}\n ${envFile}.*\n`, - ), - ); + printGitignoreWarning({ + envFile, + reason: 'no-gitignore', + log, + }); return; } const ignored = isEnvIgnoredByGit({ cwd, envFile }); if (ignored === false || ignored === null) { - log( - chalk.yellow( - `⚠️ ${envFile} is not ignored by Git (.gitignore).\n Consider adding:\n ${envFile}\n ${envFile}.*\n`, - ), - ); + printGitignoreWarning({ + envFile, + reason: 'not-ignored', + log, + }); + } +} + + +/** + * Checks if .env file has gitignore issues. + * Returns null if no issue, otherwise returns the reason. + */ +export function checkGitignoreStatus(options: GitignoreCheckOptions = {}): { + reason: 'no-gitignore' | 'not-ignored'; +} | null { + const { cwd = process.cwd(), envFile = '.env' } = options; + + const envPath = path.resolve(cwd, envFile); + if (!fs.existsSync(envPath)) return null; + if (!isGitRepo(cwd)) return null; + + const gitignorePath = path.resolve(cwd, '.gitignore'); + + if (!fs.existsSync(gitignorePath)) { + return { reason: 'no-gitignore' }; + } + + const ignored = isEnvIgnoredByGit({ cwd, envFile }); + if (ignored === false || ignored === null) { + return { reason: 'not-ignored' }; + } + + return null; +} + +/** Find the git repository root starting from startDir (walk up until ".git"). */ +export function findGitRoot(startDir: string): string | null { + let dir = path.resolve(startDir); + while (true) { + const gitDir = path.join(dir, '.git'); + if (fs.existsSync(gitDir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; // reached filesystem root + dir = parent; } + return null; } diff --git a/src/services/scanOutputToConsole.ts b/src/services/scanOutputToConsole.ts index b9a06a99..921edd06 100644 --- a/src/services/scanOutputToConsole.ts +++ b/src/services/scanOutputToConsole.ts @@ -1,11 +1,19 @@ -import chalk from 'chalk'; -import { warnIfEnvNotIgnored, isEnvIgnoredByGit } from './git.js'; -import type { - ScanUsageOptions, - ScanResult, - EnvUsage, - VariableUsages, -} from '../config/types.js'; +import path from 'path'; +import { checkGitignoreStatus } from './git.js'; +import { printGitignoreWarning } from '../ui/shared/printGitignore.js'; +import type { ScanUsageOptions, ScanResult } from '../config/types.js'; +import { printHeader } from '../ui/scan/printHeader.js'; +import { printStats } from '../ui/scan/printStats.js'; +import { printUniqueVariables } from '../ui/scan/printUniqueVariables.js'; +import { printVariables } from '../ui/scan/printVariables.js'; +import { printMissing } from '../ui/scan/printMissing.js'; +import { printUnused } from '../ui/scan/printUnused.js'; +import { printDuplicates } from '../ui/shared/printDuplicates.js'; +import { printSecrets } from '../ui/scan/printSecrets.js'; +import { printSuccess } from '../ui/shared/printSuccess.js'; +import { printStrictModeError } from '../ui/shared/printStrictModeError.js'; +import { printFixTips } from '../ui/shared/printFixTips.js'; +import { printAutoFix } from '../ui/compare/printAutoFix.js'; /** * Outputs the scan results to the console. @@ -18,255 +26,141 @@ export function outputToConsole( scanResult: ScanResult, opts: ScanUsageOptions, comparedAgainst: string, + fixContext?: { + fixApplied: boolean; + removedDuplicates: string[]; + addedEnv: string[]; + gitignoreUpdated: boolean; + }, ): { exitWithError: boolean } { let exitWithError = false; - // Show what we're comparing against - if (comparedAgainst) { - console.log( - chalk.magenta(`📋 Comparing codebase usage against: ${comparedAgainst}`), - ); - console.log(); - } + // Determine if output should be in JSON format + const isJson = opts.json ?? false; + + printHeader(comparedAgainst); // Show stats if requested - if (opts.showStats) { - console.log(chalk.magenta('📊 Scan Statistics:')); - console.log( - chalk.magenta.dim(` Files scanned: ${scanResult.stats.filesScanned}`), - ); - console.log( - chalk.magenta.dim( - ` Total usages found: ${scanResult.stats.totalUsages}`, - ), - ); - console.log( - chalk.magenta.dim( - ` Unique variables: ${scanResult.stats.uniqueVariables}`, - ), - ); - console.log(); - } + printStats(scanResult.stats, isJson, opts.showStats ?? true); - // Always show found variables when not comparing or when no missing variables + // Show used variables if any found if (scanResult.stats.uniqueVariables > 0) { - console.log( - chalk.blue( - `🌐 Found ${scanResult.stats.uniqueVariables} unique environment variables in use`, - ), + // Show unique variables found + printUniqueVariables(scanResult.stats.uniqueVariables); + // Print used variables with locations + printVariables( + scanResult.used, + opts.showStats ?? false, + isJson, ); - console.log(); - - // List all variables found (if any) - if (scanResult.stats.uniqueVariables > 0) { - // Group by variable to get unique list - const variableUsages = scanResult.used.reduce( - (acc: VariableUsages, usage: EnvUsage) => { - if (!acc[usage.variable]) { - acc[usage.variable] = []; - } - acc[usage.variable]!.push(usage); - return acc; - }, - {}, - ); - - // Display each unique variable - for (const [variable, usages] of Object.entries(variableUsages)) { - console.log(chalk.blue(` ${variable}`)); - - // Show usage details if stats are enabled - if (opts.showStats) { - const displayUsages = usages.slice(0, 2); - displayUsages.forEach((usage: EnvUsage) => { - console.log( - chalk.blue.dim( - ` Used in: ${usage.file}:${usage.line} (${usage.pattern})`, - ), - ); - }); - - if (usages.length > 2) { - console.log( - chalk.gray(` ... and ${usages.length - 2} more locations`), - ); - } - } - } - console.log(); - } } // Missing variables (used in code but not in env file) - if (scanResult.missing.length > 0) { - exitWithError = true; - const fileType = comparedAgainst || 'environment file'; - console.log(chalk.red(`❌ Missing in ${fileType}:`)); - - const grouped = scanResult.missing.reduce( - (acc: VariableUsages, variable: string) => { - const usages = scanResult.used.filter( - (u: EnvUsage) => u.variable === variable, - ); - acc[variable] = usages; - return acc; - }, - {}, - ); - - for (const [variable, usages] of Object.entries(grouped)) { - console.log(chalk.red(` - ${variable}`)); - - // Show first few usages - const maxShow = 3; - usages.slice(0, maxShow).forEach((usage: EnvUsage) => { - console.log( - chalk.red.dim( - ` Used in: ${usage.file}:${usage.line} (${usage.pattern})`, - ), - ); - }); - - if (usages.length > maxShow) { - console.log( - chalk.gray(` ... and ${usages.length - maxShow} more locations`), - ); - } - } - console.log(); - - // CI mode specific message - if (opts.isCiMode) { - console.log( - chalk.red( - `💥 Found ${scanResult.missing.length} missing environment variable(s).`, - ), - ); - console.log( - chalk.red( - ` Add these variables to ${comparedAgainst || 'your environment file'} to fix this error.`, - ), - ); - console.log(); - } - } - - // Unused variables (in env file but not used in code) - if (opts.showUnused && scanResult.unused.length > 0) { - const fileType = comparedAgainst || 'environment file'; - console.log( - chalk.yellow(`⚠️ Unused in codebase (defined in ${fileType}):`), - ); - scanResult.unused.forEach((variable: string) => { - console.log(chalk.yellow(` - ${variable}`)); - }); - console.log(); - } - - // Show duplicates if found - NOW AFTER UNUSED VARIABLES - if (scanResult.duplicates?.env && scanResult.duplicates.env.length > 0) { - console.log( - chalk.yellow( - `⚠️ Duplicate keys in ${comparedAgainst} (last occurrence wins):`, - ), - ); - scanResult.duplicates.env.forEach(({ key, count }) => - console.log(chalk.yellow(` - ${key} (${count} occurrences)`)), - ); - console.log(); - } - if ( - scanResult.duplicates?.example && - scanResult.duplicates.example.length > 0 + printMissing( + scanResult.missing, + scanResult.used, + comparedAgainst, + opts.isCiMode ?? false, + isJson, + ) ) { - console.log( - chalk.yellow( - '⚠️ Duplicate keys in example file (last occurrence wins):', - ), - ); - scanResult.duplicates.example.forEach(({ key, count }) => - console.log(chalk.yellow(` - ${key} (${count} occurrences)`)), - ); - console.log(); + exitWithError = true; } - if (scanResult.secrets && scanResult.secrets.length > 0) { - console.log(chalk.yellow('🔒 Potential secrets detected in codebase:')); - const byFile = new Map(); - for (const f of scanResult.secrets) { - if (!byFile.has(f.file)) byFile.set(f.file, []); - byFile.get(f.file)!.push(f); - } - for (const [file, findings] of byFile) { - console.log(chalk.bold(` ${file}`)); - for (const f of findings) { - console.log( - chalk.yellow( - ` - Line ${f.line}: ${f.message}\n ${chalk.dim(f.snippet)}`, - ), - ); - } - } - console.log(); - } + // Unused + printUnused( + scanResult.unused, + comparedAgainst, + opts.showUnused ?? false, + isJson, + ); + + // Duplicates + printDuplicates( + comparedAgainst || '.env', + 'example file', + scanResult.duplicates?.env ?? [], + scanResult.duplicates?.example ?? [], + isJson, + ); + + // Print potential secrets found + printSecrets(scanResult.secrets ?? [], isJson); // Success message for env file comparison if ( comparedAgainst && scanResult.missing.length === 0 && - scanResult.secrets.length > 0 && + (scanResult.secrets?.length ?? 0) === 0 && scanResult.used.length > 0 ) { - console.log( - chalk.green( - `✅ All used environment variables are defined in ${comparedAgainst}`, - ), + printSuccess( + isJson, + 'scan', + comparedAgainst, + scanResult.unused, + opts.showUnused ?? true, ); - - if (opts.showUnused && scanResult.unused.length === 0) { - console.log(chalk.green('✅ No unused environment variables found')); - } - console.log(); } - let envNotIgnored = false; - if (!opts.json) { - warnIfEnvNotIgnored({ cwd: opts.cwd, envFile: '.env' }); + // Gitignore check + const gitignoreIssue = checkGitignoreStatus({ + cwd: opts.cwd, + envFile: '.env', + }); - const ignored = isEnvIgnoredByGit({ cwd: opts.cwd, envFile: '.env' }); - if (ignored === false || ignored === null) { - envNotIgnored = true; - } + if (gitignoreIssue && !opts.json) { + printGitignoreWarning({ + envFile: '.env', + reason: gitignoreIssue.reason, + }); } - if (opts.strict) { - const hasWarnings = - scanResult.unused.length > 0 || - (scanResult.duplicates?.env?.length ?? 0) > 0 || - (scanResult.duplicates?.example?.length ?? 0) > 0 || - (scanResult.secrets?.length ?? 0) > 0 || - envNotIgnored; + const hasGitignoreIssue = gitignoreIssue !== null; - if (hasWarnings) { - exitWithError = true; + if (opts.strict) { + const exit = printStrictModeError( + { + unused: scanResult.unused.length, + duplicatesEnv: scanResult.duplicates?.env?.length ?? 0, + duplicatesEx: scanResult.duplicates?.example?.length ?? 0, + secrets: scanResult.secrets?.length ?? 0, + hasGitignoreIssue, + }, + isJson, + ); - const warnings: string[] = []; - if (scanResult.unused.length > 0) warnings.push('unused variables'); - if ((scanResult.duplicates?.env?.length ?? 0) > 0) - warnings.push('duplicate keys in env'); - if ((scanResult.duplicates?.example?.length ?? 0) > 0) - warnings.push('duplicate keys in example'); - if ((scanResult.secrets?.length ?? 0) > 0) - warnings.push('potential secrets'); - if (envNotIgnored) warnings.push('.env not ignored by git'); + if (exit) exitWithError = true; + } - console.log( - chalk.red(`💥 Strict mode: Error on warnings → ${warnings.join(', ')}`), - ); - console.log(); - } + if (opts.fix && fixContext) { + printAutoFix( + fixContext.fixApplied, + { + removedDuplicates: fixContext.removedDuplicates, + addedEnv: fixContext.addedEnv, + addedExample: opts.examplePath ? fixContext.addedEnv : [], + }, + comparedAgainst || '.env', + opts.examplePath ? path.basename(opts.examplePath) : 'example file', + isJson, + fixContext.gitignoreUpdated, + ); } + // Filtered results for fix tips + printFixTips( + { + missing: scanResult.missing, + duplicatesEnv: scanResult.duplicates?.env ?? [], + duplicatesEx: scanResult.duplicates?.example ?? [], + gitignoreIssue: hasGitignoreIssue ? { reason: 'not-ignored' } : null, + }, + hasGitignoreIssue, + isJson, + opts.fix ?? false, +); + return { exitWithError }; } diff --git a/src/ui/compare/printAutoFix.ts b/src/ui/compare/printAutoFix.ts new file mode 100644 index 00000000..6f12f5f4 --- /dev/null +++ b/src/ui/compare/printAutoFix.ts @@ -0,0 +1,60 @@ +import chalk from 'chalk'; + +export interface AutoFixResult { + removedDuplicates: string[]; + addedEnv: string[]; + addedExample: string[]; +} + +/** + * Prints the result of the auto-fix operation. + * @param changed - Whether any changes were made. + * @param result - The result of the auto-fix operation. + * @param envName - The name of the environment file. + * @param exampleName - The name of the example file. + * @param json - Whether to output in JSON format. + * @returns void + */ +export function printAutoFix( + changed: boolean, + result: AutoFixResult, + envName: string, + exampleName: string, + json: boolean, + gitignoreUpdated: boolean, +): void { + if (json) return; + + if (changed) { + console.log(chalk.green('✅ Auto-fix applied:')); + if (result.removedDuplicates.length) { + console.log( + chalk.green( + ` - Removed ${result.removedDuplicates.length} duplicate keys from ${envName}: ${result.removedDuplicates.join(', ')}`, + ), + ); + } + if (result.addedEnv.length) { + console.log( + chalk.green( + ` - Added ${result.addedEnv.length} missing keys to ${envName}: ${result.addedEnv.join(', ')}`, + ), + ); + } + if (result.addedExample.length) { + console.log( + chalk.green( + ` - Synced ${result.addedExample.length} keys to ${exampleName}: ${result.addedExample.join(', ')}`, + ), + ); + } + if (gitignoreUpdated) { + console.log( + chalk.green(` - Added ${envName} to .gitignore`), + ); + } + } else { + console.log(chalk.green('✅ Auto-fix applied: no changes needed.')); + } + console.log(); +} diff --git a/src/ui/compare/printErrorNotFound.ts b/src/ui/compare/printErrorNotFound.ts new file mode 100644 index 00000000..29a56f8a --- /dev/null +++ b/src/ui/compare/printErrorNotFound.ts @@ -0,0 +1,27 @@ +import chalk from 'chalk'; +import path from 'path'; + +/** + * Prints error messages if env/example files are missing. + * @param envExists Whether the env file exists + * @param exExists Whether the example file exists + * @param envFlag The path to the env file + * @param exampleFlag The path to the example file + */ +export function printErrorNotFound( + envExists: boolean, + exExists: boolean, + envFlag: string, + exampleFlag: string, +): void { + if (!envExists) { + console.error( + chalk.red(`❌ Error: --env file not found: ${path.basename(envFlag)}`), + ); + } + if (!exExists) { + console.error( + chalk.red(`❌ Error: --example file not found: ${path.basename(exampleFlag)}`), + ); + } +} diff --git a/src/ui/compare/printHeader.ts b/src/ui/compare/printHeader.ts new file mode 100644 index 00000000..9a66523b --- /dev/null +++ b/src/ui/compare/printHeader.ts @@ -0,0 +1,24 @@ +import chalk from 'chalk'; + +/** + * Prints the header for the comparison output. + * @param envName The name of the environment file. + * @param exampleName The name of the example file. + * @param json Whether to output in JSON format. + * @param skipping Whether the comparison is being skipped. + * @returns void + */ +export function printHeader( + envName: string, + exampleName: string, + json: boolean, + skipping: boolean, +) { + if (json) return; + console.log(); + console.log(chalk.blue(`🔍 Comparing ${envName} ↔ ${exampleName}...`)); + if (skipping) { + console.log(chalk.yellow('⚠️ Skipping: missing matching example file.')); + } + console.log(); +} diff --git a/src/ui/compare/printIssues.ts b/src/ui/compare/printIssues.ts new file mode 100644 index 00000000..7e5df197 --- /dev/null +++ b/src/ui/compare/printIssues.ts @@ -0,0 +1,40 @@ +import chalk from 'chalk'; +import { type Filtered } from '../../config/types.js'; + +/** + * Prints the issues found during the comparison. + * @param filtered The filtered comparison results. + * @param json Whether to output in JSON format. + * @returns void + */ +export function printIssues( + filtered: Filtered, + json: boolean, +) { + if (json) return; + if (filtered.missing.length) { + const header = chalk.red('❌ Missing keys:'); + console.log(header); + filtered.missing.forEach((key) => console.log(chalk.red(` - ${key}`))); + console.log(); + } + if (filtered.extra?.length) { + console.log(chalk.yellow('⚠️ Extra keys (not in example):')); + filtered.extra.forEach((key) => console.log(chalk.yellow(` - ${key}`))); + console.log(); + } + if (filtered.empty?.length) { + console.log(chalk.yellow('⚠️ Empty values:')); + filtered.empty.forEach((key) => console.log(chalk.yellow(` - ${key}`))); + console.log(); + } + if (filtered.mismatches?.length) { + console.log(chalk.yellow('⚠️ Value mismatches:')); + filtered.mismatches.forEach(({ key, expected, actual }) => + console.log( + chalk.yellow(` - ${key}: expected '${expected}', but got '${actual}'`), + ), + ); + console.log(); + } +} \ No newline at end of file diff --git a/src/ui/compare/printPrompt.ts b/src/ui/compare/printPrompt.ts new file mode 100644 index 00000000..8c624f66 --- /dev/null +++ b/src/ui/compare/printPrompt.ts @@ -0,0 +1,38 @@ +import chalk from 'chalk'; +import path from 'path'; + +/** + * Prompt messages for user interactions. + */ +export const printPrompt = { + noEnvFound() { + console.log( + chalk.yellow('⚠️ No .env* or .env.example file found. Skipping comparison.'), + ); + }, + + missingEnv(envPath: string) { + console.log(); + console.log(chalk.yellow(`📄 ${path.basename(envPath)} file not found.`)); + }, + + skipCreation(fileType: string) { + console.log(chalk.gray(`🚫 Skipping ${fileType} creation.`)); + }, + + envCreated(envPath: string, examplePath: string) { + console.log( + chalk.green( + `✅ ${path.basename(envPath)} file created successfully from ${path.basename(examplePath)}.`, + ), + ); + }, + + exampleCreated(examplePath: string, envPath: string) { + console.log( + chalk.green( + `✅ ${path.basename(examplePath)} file created successfully from ${path.basename(envPath)}.`, + ), + ); + }, +}; diff --git a/src/ui/compare/printStats.ts b/src/ui/compare/printStats.ts new file mode 100644 index 00000000..e6bbbefe --- /dev/null +++ b/src/ui/compare/printStats.ts @@ -0,0 +1,57 @@ +import chalk from 'chalk'; +import { type Filtered } from '../../config/types.js'; + +export interface CompareStats { + envCount: number; + exampleCount: number; + sharedCount: number; + duplicateCount: number; // sum of (count - 1) + valueMismatchCount: number; +}; + +/** + * Print comparison statistics between two environment files. + * @param envName The name of the environment file. + * @param exampleName The name of the example file. + * @param s The comparison statistics. + * @param filtered The filtered keys. + * @param json Whether to output in JSON format. + * @param showStats Whether to show statistics. + * @returns + */ +export function printStats( + envName: string, + exampleName: string, + s: CompareStats, + filtered: Pick, + json: boolean, + showStats: boolean, +) { + if (json || !showStats) return; + console.log(chalk.magenta('📊 Compare Statistics:')); + console.log(chalk.magenta.dim(` Keys in ${envName}: ${s.envCount}`)); + console.log( + chalk.magenta.dim(` Keys in ${exampleName}: ${s.exampleCount}`), + ); + console.log(chalk.magenta.dim(` Shared keys: ${s.sharedCount}`)); + console.log( + chalk.magenta.dim(` Missing (in ${envName}): ${filtered.missing.length}`), + ); + if (filtered.extra?.length) { + console.log( + chalk.magenta.dim( + ` Extra (not in ${exampleName}): ${filtered.extra.length}`, + ), + ); + } + if (filtered.empty?.length) { + console.log( + chalk.magenta.dim(` Empty values: ${filtered.empty.length}`), + ); + } + console.log(chalk.magenta.dim(` Duplicate keys: ${s.duplicateCount}`)); + console.log( + chalk.magenta.dim(` Value mismatches: ${s.valueMismatchCount}`), + ); + console.log(); +} \ No newline at end of file diff --git a/src/ui/scan/printComparisonError.ts b/src/ui/scan/printComparisonError.ts new file mode 100644 index 00000000..9763951a --- /dev/null +++ b/src/ui/scan/printComparisonError.ts @@ -0,0 +1,27 @@ +import chalk from 'chalk'; + +/** + * Prints a comparison error message. + * @param message The error message to print + * @param shouldExit Whether the process should exit + * @param json Whether to format the output as JSON + * @returns An object indicating whether the process should exit + */ +export function printComparisonError( + message: string, + shouldExit: boolean, + json: boolean, +): { exit: boolean } { + const errorMessage = `⚠️ ${message}`; + + if (shouldExit) { + console.log(chalk.red(errorMessage.replace('⚠️', '❌'))); + return { exit: true }; + } + + if (!json) { + console.log(chalk.yellow(errorMessage)); + } + + return { exit: false }; +} diff --git a/src/ui/scan/printHeader.ts b/src/ui/scan/printHeader.ts new file mode 100644 index 00000000..e2af21f5 --- /dev/null +++ b/src/ui/scan/printHeader.ts @@ -0,0 +1,20 @@ +import chalk from 'chalk'; + +/** + * Prints the header for the scanning output. + * @param comparedAgainst Optional string indicating what the codebase is being compared against. + * @returns void + */ +export function printHeader(comparedAgainst?: string): void { + console.log(); + console.log( + chalk.blue('🔍 Scanning codebase for environment variable usage...'), + ); + if (comparedAgainst) { + console.log(); + console.log( + chalk.magenta(`📋 Comparing codebase usage against: ${comparedAgainst}`), + ); + } + console.log(); +} diff --git a/src/ui/scan/printMissing.ts b/src/ui/scan/printMissing.ts new file mode 100644 index 00000000..f5c47a01 --- /dev/null +++ b/src/ui/scan/printMissing.ts @@ -0,0 +1,72 @@ +import chalk from 'chalk'; +import type { EnvUsage, VariableUsages } from '../../config/types.js'; + +/** + * Print missing environment variables (used in code but not in env file). + * + * @param missing - List of missing variables + * @param used - All usages found in the codebase + * @param comparedAgainst - Name of the env file or example file + * @param isCiMode - Whether we are in CI mode (extra error message) + * @param json - Whether to output in JSON format + * @returns true if any missing variables were printed + */ +export function printMissing( + missing: string[], + used: EnvUsage[], + comparedAgainst: string, + isCiMode: boolean, + json: boolean, +): boolean { + if (json) return false; + if (missing.length === 0) return false; + + const fileType = comparedAgainst || 'environment file'; + console.log(chalk.red(`❌ Missing in ${fileType}:`)); + + // Group by variable → find their usages + const grouped = missing.reduce( + (acc: VariableUsages, variable: string) => { + const usages = used.filter((u: EnvUsage) => u.variable === variable); + acc[variable] = usages; + return acc; + }, + {}, + ); + + for (const [variable, usages] of Object.entries(grouped)) { + console.log(chalk.red(` - ${variable}`)); + + const maxShow = 3; + usages.slice(0, maxShow).forEach((usage: EnvUsage) => { + console.log( + chalk.red.dim( + ` Used in: ${usage.file}:${usage.line} (${usage.pattern})`, + ), + ); + }); + + if (usages.length > maxShow) { + console.log( + chalk.gray(` ... and ${usages.length - maxShow} more locations`), + ); + } + } + console.log(); + + if (isCiMode) { + console.log( + chalk.red( + `💥 Found ${missing.length} missing environment variable(s).`, + ), + ); + console.log( + chalk.red( + ` Add these variables to ${fileType} to fix this error.`, + ), + ); + console.log(); + } + + return true; +} diff --git a/src/ui/scan/printMissingExample.ts b/src/ui/scan/printMissingExample.ts new file mode 100644 index 00000000..7e39ad74 --- /dev/null +++ b/src/ui/scan/printMissingExample.ts @@ -0,0 +1,29 @@ +import chalk from 'chalk'; +import fs from 'fs'; +import { resolveFromCwd } from '../../core/helpers/resolveFromCwd.js'; +import type { ScanUsageOptions } from '../../config/types.js'; + +/** + * Print message if the specified example file is missing. + * Handles CI vs interactive mode output. + * @param opts Scan options + * @returns true if missing and in CI mode (should exit), false otherwise + */ +export function printMissingExample(opts: ScanUsageOptions): boolean { + if (!opts.examplePath) return false; + + const exampleAbs = resolveFromCwd(opts.cwd, opts.examplePath); + const missing = !fs.existsSync(exampleAbs); + + if (missing) { + const msgText = `Missing specified example file: ${opts.examplePath}`; + if (opts.isCiMode) { + console.log(chalk.red('❌ ' + msgText)); + return true; + } else if (!opts.json) { + console.log(chalk.yellow('⚠️ ' + msgText)); + } + } + + return false; +} diff --git a/src/ui/scan/printSecrets.ts b/src/ui/scan/printSecrets.ts new file mode 100644 index 00000000..c8b6efd0 --- /dev/null +++ b/src/ui/scan/printSecrets.ts @@ -0,0 +1,41 @@ +import chalk from 'chalk'; + +export interface SecretFinding { + file: string; + line: number; + message: string; + snippet: string; +} + +/** + * Print potential secrets detected in the codebase. + * + * @param secrets - Array of secret findings + * @param json - Whether to output in JSON format + */ +export function printSecrets(secrets: SecretFinding[], json: boolean): void { + if (json) return; + if (!secrets || secrets.length === 0) return; + + console.log(chalk.yellow('🔒 Potential secrets detected in codebase:')); + + // Group by file + const byFile = new Map(); + for (const f of secrets) { + if (!byFile.has(f.file)) byFile.set(f.file, []); + byFile.get(f.file)!.push(f); + } + + for (const [file, findings] of byFile) { + console.log(chalk.bold(` ${file}`)); + for (const f of findings) { + console.log( + chalk.yellow( + ` - Line ${f.line}: ${f.message}\n ${chalk.dim(f.snippet)}`, + ), + ); + } + } + + console.log(); +} diff --git a/src/ui/scan/printStats.ts b/src/ui/scan/printStats.ts new file mode 100644 index 00000000..1c1c0088 --- /dev/null +++ b/src/ui/scan/printStats.ts @@ -0,0 +1,26 @@ +import chalk from "chalk"; + +export interface ScanStats { + filesScanned: number; + totalUsages: number; + uniqueVariables: number; +} + +/** + * Print scan statistics for codebase scanning. + * @param stats The scan statistics + * @param json Whether to output in JSON format + * @param showStats Whether to show statistics + */ +export function printStats( + stats: ScanStats, + json: boolean, + showStats: boolean +) { + if (json || !showStats) return; + console.log(chalk.magenta("📊 Scan Statistics:")); + console.log(chalk.magenta.dim(` Files scanned: ${stats.filesScanned}`)); + console.log(chalk.magenta.dim(` Total usages found: ${stats.totalUsages}`)); + console.log(chalk.magenta.dim(` Unique variables: ${stats.uniqueVariables}`)); + console.log(); +} diff --git a/src/ui/scan/printUniqueVariables.ts b/src/ui/scan/printUniqueVariables.ts new file mode 100644 index 00000000..a3acbe10 --- /dev/null +++ b/src/ui/scan/printUniqueVariables.ts @@ -0,0 +1,19 @@ +import chalk from 'chalk'; + +/** + * Prints the unique environment variables found in the codebase. + * @param variables Array of unique environment variable names + * @returns void + */ +export function printUniqueVariables(variables: number): void { + if (variables === 0) { + return; + } + + console.log( + chalk.blue( + `🌐 Found ${variables} unique environment variables in use`, + ), + ); + console.log(); +} diff --git a/src/ui/scan/printUnused.ts b/src/ui/scan/printUnused.ts new file mode 100644 index 00000000..c078b32f --- /dev/null +++ b/src/ui/scan/printUnused.ts @@ -0,0 +1,28 @@ +import chalk from 'chalk'; + +/** + * Print unused environment variables (defined in env but not used in code). + * + * @param unused - Array of unused variable names + * @param comparedAgainst - File name (.env eller andet) + * @param showUnused - Whether unused should be shown at all + * @param json - Whether to output in JSON format + */ +export function printUnused( + unused: string[], + comparedAgainst: string, + showUnused: boolean, + json: boolean, +): void { + if (json || !showUnused) return; + if (unused.length === 0) return; + + const fileType = comparedAgainst || 'environment file'; + console.log(chalk.yellow(`⚠️ Unused in codebase (defined in ${fileType}):`)); + + unused.forEach((variable) => { + console.log(chalk.yellow(` - ${variable}`)); + }); + + console.log(); +} diff --git a/src/ui/scan/printVariables.ts b/src/ui/scan/printVariables.ts new file mode 100644 index 00000000..ae0a2088 --- /dev/null +++ b/src/ui/scan/printVariables.ts @@ -0,0 +1,56 @@ +import chalk from 'chalk'; +import type { EnvUsage, VariableUsages } from '../../config/types.js'; + +/** + * Print all unique variables and their usage locations. + * + * @param usages - Array of environment variable usages + * @param showStats - Whether to show usage details (files/lines) + * @param json - Whether to output in JSON format + */ +export function printVariables( + usages: EnvUsage[], + showStats: boolean, + json: boolean, +): void { + if (json) return; + if (usages.length === 0) return; + + // Group by variable + const variableUsages = usages.reduce( + (acc: VariableUsages, usage: EnvUsage) => { + if (!acc[usage.variable]) { + acc[usage.variable] = []; + } + acc[usage.variable]!.push(usage); + return acc; + }, + {}, + ); + + // Display each unique variable + for (const [variable, variableUsageList] of Object.entries(variableUsages)) { + console.log(chalk.blue(` ${variable}`)); + + if (showStats) { + const displayUsages = variableUsageList.slice(0, 2); + + displayUsages.forEach((usage: EnvUsage) => { + console.log( + chalk.blue.dim( + ` Used in: ${usage.file}:${usage.line} (${usage.pattern})`, + ), + ); + }); + + if (variableUsageList.length > 2) { + console.log( + chalk.gray( + ` ... and ${variableUsageList.length - 2} more locations`, + ), + ); + } + } + } + console.log(); +} diff --git a/src/ui/shared/printDuplicates.ts b/src/ui/shared/printDuplicates.ts new file mode 100644 index 00000000..35427fc2 --- /dev/null +++ b/src/ui/shared/printDuplicates.ts @@ -0,0 +1,40 @@ +import chalk from 'chalk'; + +/** + * Prints duplicate keys found in the environment and example files. + * @param envName The name of the environment file. + * @param exampleName The name of the example file. + * @param dEnv Array of duplicate keys in the environment file with their counts. + * @param dEx Array of duplicate keys in the example file with their counts. + * @param json Whether to output in JSON format. + * @returns void + */ +export function printDuplicates( + envName: string, + exampleName: string, + dEnv: Array<{ key: string; count: number }>, + dEx: Array<{ key: string; count: number }>, + json: boolean, +) { + if (json) return; + if (dEnv.length) { + console.log( + chalk.yellow(`⚠️ Duplicate keys in ${envName} (last occurrence wins):`), + ); + dEnv.forEach(({ key, count }) => + console.log(chalk.yellow(` - ${key} (${count} occurrences)`)), + ); + console.log(); + } + if (dEx.length) { + console.log( + chalk.yellow( + `⚠️ Duplicate keys in ${exampleName} (last occurrence wins):`, + ), + ); + dEx.forEach(({ key, count }) => + console.log(chalk.yellow(` - ${key} (${count} occurrences)`)), + ); + console.log(); + } +} \ No newline at end of file diff --git a/src/ui/shared/printFixTips.ts b/src/ui/shared/printFixTips.ts new file mode 100644 index 00000000..323ad6bd --- /dev/null +++ b/src/ui/shared/printFixTips.ts @@ -0,0 +1,47 @@ +import chalk from 'chalk'; +import { type Filtered } from '../../config/types.js'; + +/** + * Prints tips for fixing issues found during the comparison. + * @param filtered The filtered comparison results. + * @param envNotIgnored Whether the .env file is not ignored by git. + * @param json Whether to output in JSON format. + * @param fix Whether to apply fixes. + * @returns void + */ +export function printFixTips( + filtered: Filtered, + envNotIgnored: boolean, + json: boolean, + fix: boolean, +) { + if (json || fix) return; + + const hasMissing = filtered.missing.length > 0; + const hasDupEnv = filtered.duplicatesEnv.length > 0; + + let tip: string | null = null; + if (hasMissing && hasDupEnv && envNotIgnored) { + tip = + '💡 Tip: Run with `--fix` to add missing keys, remove duplicates and add .env to .gitignore'; + } else if (hasMissing && hasDupEnv) { + tip = '💡 Tip: Run with `--fix` to add missing keys and remove duplicates'; + } else if (hasDupEnv && envNotIgnored) { + tip = + '💡 Tip: Run with `--fix` to remove duplicate keys and add .env to .gitignore'; + } else if (hasMissing && envNotIgnored) { + tip = + '💡 Tip: Run with `--fix` to add missing keys and add .env to .gitignore'; + } else if (hasMissing) { + tip = '💡 Tip: Run with `--fix` to add missing keys'; + } else if (hasDupEnv) { + tip = '💡 Tip: Run with `--fix` to remove duplicate keys'; + } else if (envNotIgnored) { + tip = '💡 Tip: Run with `--fix` to ensure .env is added to .gitignore'; + } + + if (tip) { + console.log(chalk.gray(tip)); + console.log(); + } +} \ No newline at end of file diff --git a/src/ui/shared/printGitignore.ts b/src/ui/shared/printGitignore.ts new file mode 100644 index 00000000..f8d5b096 --- /dev/null +++ b/src/ui/shared/printGitignore.ts @@ -0,0 +1,36 @@ +import chalk from 'chalk'; + +type GitignoreWarningOptions = { + envFile: string; + reason: 'no-gitignore' | 'not-ignored'; + log?: (msg: string) => void; +}; + +/** + * Logs a warning about .env not being ignored by Git. + * @param options - Options for the gitignore warning. + * @returns void + */ +export function printGitignoreWarning(options: GitignoreWarningOptions): void { + const { envFile, reason, log = console.log } = options; + + if (reason === 'no-gitignore') { + log( + chalk.yellow( + `⚠️ No .gitignore found – your ${envFile} may be committed.\n` + + ` Add:\n` + + ` ${envFile}\n` + + ` ${envFile}.*\n` + ) + ); + } else { + log( + chalk.yellow( + `⚠️ ${envFile} is not ignored by Git (.gitignore).\n` + + ` Consider adding:\n` + + ` ${envFile}\n` + + ` ${envFile}.*\n` + ) + ); + } +} \ No newline at end of file diff --git a/src/ui/shared/printOptionErrors.ts b/src/ui/shared/printOptionErrors.ts new file mode 100644 index 00000000..3a9606cd --- /dev/null +++ b/src/ui/shared/printOptionErrors.ts @@ -0,0 +1,38 @@ +import chalk from 'chalk'; + +/** + * Prints an error for invalid --only categories and exits. + * @param flagName - The name of the flag. + * @param bad - The invalid values. + * @param allowed - The allowed values. + */ +export function printInvalidCategory( + flagName: string, + bad: string[], + allowed: readonly string[], +): never { + console.error( + chalk.red( + `❌ Error: invalid ${flagName} value(s): ${bad.join(', ')}.\n` + + ` Allowed: ${allowed.join(', ')}`, + ), + ); + process.exit(1); +} + + +/** + * Prints an error for invalid regex patterns and exits. + * @param pattern - The invalid regex pattern. + */ +export function printInvalidRegex(pattern: string): never { + console.error(chalk.red(`❌ Error: invalid --ignore-regex pattern: ${pattern}`)); + process.exit(1); +} + +/** + * Prints a warning when both --ci and --yes are provided. + */ +export function printCiYesWarning(): void { + console.log(chalk.yellow('⚠️ Both --ci and --yes provided; proceeding with --yes.')); +} diff --git a/src/ui/shared/printStrictModeError.ts b/src/ui/shared/printStrictModeError.ts new file mode 100644 index 00000000..12e11eae --- /dev/null +++ b/src/ui/shared/printStrictModeError.ts @@ -0,0 +1,40 @@ +import chalk from 'chalk'; + +export interface StrictModeContext { + unused: number; + duplicatesEnv: number; + duplicatesEx: number; + secrets: number; + hasGitignoreIssue: boolean; +} + +/** + * Prints a strict-mode error if warnings exist. + * + * @param ctx - Counts of warnings/issues + * @param json - Whether to output in JSON format + * @returns true if exitWithError should be set + */ +export function printStrictModeError( + ctx: StrictModeContext, + json: boolean, +): boolean { + if (json) return false; + + const warnings: string[] = []; + + if (ctx.unused > 0) warnings.push('unused variables'); + if (ctx.duplicatesEnv > 0) warnings.push('duplicate keys in env'); + if (ctx.duplicatesEx > 0) warnings.push('duplicate keys in example'); + if (ctx.secrets > 0) warnings.push('potential secrets'); + if (ctx.hasGitignoreIssue) warnings.push('.env not ignored by git'); + + if (warnings.length === 0) return false; + + console.log( + chalk.red(`💥 Strict mode: Error on warnings → ${warnings.join(', ')}`), + ); + console.log(); + + return true; +} diff --git a/src/ui/shared/printSuccess.ts b/src/ui/shared/printSuccess.ts new file mode 100644 index 00000000..85cbc9d6 --- /dev/null +++ b/src/ui/shared/printSuccess.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk'; + +/** + * Prints success messages when everything is OK. + * + * @param json - Whether to output in JSON format. + * @param mode - "compare" or "scan" mode. + * @param comparedAgainst - Name of env/example file (optional). + * @param unused - List of unused variables (optional, only for scan). + * @param showUnused - Whether to print unused success info. + */ +export function printSuccess( + json: boolean, + mode: 'compare' | 'scan' = 'compare', + comparedAgainst?: string, + unused: string[] = [], + showUnused = false, +): void { + if (json) return; + + if (mode === 'compare') { + console.log(chalk.green('✅ All keys match.')); + console.log(); + return; + } + + if (mode === 'scan' && comparedAgainst) { + console.log( + chalk.green( + `✅ All used environment variables are defined in ${comparedAgainst}`, + ), + ); + + if (showUnused && unused.length === 0) { + console.log(chalk.green('✅ No unused environment variables found')); + } + console.log(); + } +} diff --git a/src/ui/shared/setupGlobalConfig.ts b/src/ui/shared/setupGlobalConfig.ts new file mode 100644 index 00000000..3614204c --- /dev/null +++ b/src/ui/shared/setupGlobalConfig.ts @@ -0,0 +1,12 @@ +import chalk from 'chalk'; +import { type Options } from '../../config/types.js'; + +/** + * @param opts - Options containing noColor flag + * @returns void + */ +export function setupGlobalConfig(opts: Options) { + if (opts.noColor) { + chalk.level = 0; // disable colors globally + } +} \ No newline at end of file diff --git a/test/e2e/cli.autoscan.e2e.test.ts b/test/e2e/cli.autoscan.e2e.test.ts index 74af8eda..c987d5ba 100644 --- a/test/e2e/cli.autoscan.e2e.test.ts +++ b/test/e2e/cli.autoscan.e2e.test.ts @@ -58,10 +58,50 @@ describe('no-flag autoscan', () => { ); const res = runCli(cwd, []); - console.log('stdout:', res.stdout); - console.log('stderr:', res.stderr); - expect(res.status).toBe(0); expect(res.stdout).toContain('.env is not ignored by Git'); }); + + it('will warn about .env not ignored by .gitignore --compare flag', () => { + const cwd = tmpDir(); + + fs.mkdirSync(path.join(cwd, '.git')); + fs.writeFileSync(path.join(cwd, '.env'), 'API_KEY=test\n'); + fs.writeFileSync(path.join(cwd, '.env.example'), 'API_KEY=example\n'); + + fs.writeFileSync(path.join(cwd, '.gitignore'), 'node_modules\n'); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.ts'), + `const apiKey = process.env.API_KEY;`.trimStart(), + ); + + const res = runCli(cwd, ['--compare']); + expect(res.status).toBe(0); + expect(res.stdout).toContain('.env is not ignored by Git'); + }); + + it('will auto-fix .env not ignored by .gitignore with --fix', () => { + const cwd = tmpDir(); + + fs.mkdirSync(path.join(cwd, '.git')); + fs.writeFileSync(path.join(cwd, '.env'), 'API_KEY=test\n'); + + fs.writeFileSync(path.join(cwd, '.gitignore'), 'node_modules\n'); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.ts'), + `const apiKey = process.env.API_KEY;`.trimStart(), + ); + + const res = runCli(cwd, ['--fix']); + expect(res.status).toBe(0); + expect(res.stdout).toContain('Added .env to .gitignore'); + + const gitignore = fs.readFileSync(path.join(cwd, '.gitignore'), 'utf-8'); + expect(gitignore).toContain('.env'); + expect(gitignore).toContain('.env.*'); + }); }); diff --git a/test/e2e/cli.compare.e2e.test.ts b/test/e2e/cli.compare.e2e.test.ts new file mode 100644 index 00000000..e6b20909 --- /dev/null +++ b/test/e2e/cli.compare.e2e.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { makeTmpDir, rmrf } from '../utils/fs-helpers.js'; +import { buildOnce, runCli, cleanupBuild } from '../utils/cli-helpers.js'; + +const tmpDirs: string[] = []; + +beforeAll(() => { + buildOnce(); +}); + +afterAll(() => { + cleanupBuild(); +}); + +afterEach(() => { + while (tmpDirs.length) { + const dir = tmpDirs.pop(); + if (dir) rmrf(dir); + } +}); + +function tmpDir() { + const dir = makeTmpDir(); + tmpDirs.push(dir); + return dir; +} + +describe('added .env to gitignore with --compare and --fix', () => { + it('will warn about .env not ignored by .gitignore', () => { + const cwd = tmpDir(); + + fs.mkdirSync(path.join(cwd, '.git')); + fs.writeFileSync(path.join(cwd, '.env'), 'API_KEY=test\n'); + fs.writeFileSync(path.join(cwd, '.env.example'), 'API_KEY=test\n'); + + fs.writeFileSync(path.join(cwd, '.gitignore'), 'node_modules\n'); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.ts'), + `const apiKey = process.env.API_KEY;`.trimStart(), + ); + + const res = runCli(cwd, ['--compare', '--fix']); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('Auto-fix applied:'); + expect(res.stdout).toContain('Added .env to .gitignore'); + }); +}); diff --git a/test/e2e/cli.flags.e2e.test.ts b/test/e2e/cli.flags.e2e.test.ts index 85cc9fe0..54d48765 100644 --- a/test/e2e/cli.flags.e2e.test.ts +++ b/test/e2e/cli.flags.e2e.test.ts @@ -227,7 +227,7 @@ describe('duplicate detection', () => { fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\nFOO=2\n'); fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\n'); const res = runCli(cwd, ['--compare']); - expect(res.status).toBe(1); + expect(res.status).toBe(0); expect(res.stdout).toContain( 'Duplicate keys in .env (last occurrence wins):', ); @@ -239,7 +239,7 @@ describe('duplicate detection', () => { fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\n'); fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\nFOO=\n'); const res = runCli(cwd, ['--compare']); - expect(res.status).toBe(1); + expect(res.status).toBe(0); expect(res.stdout).toContain( 'Duplicate keys in .env.example (last occurrence wins):', ); @@ -309,14 +309,14 @@ describe('duplicate detection', () => { expect(res.stdout).toContain('❌ Missing keys:'); expect(res.stdout).not.toContain('Extra keys'); }); - it('fails on flag only extra', () => { + it('warns on flag only extra', () => { const cwd = tmpDir(); fs.writeFileSync(path.join(cwd, '.env'), 'A=1\nC=2\nD=3\n'); fs.writeFileSync(path.join(cwd, '.env.example'), 'A=\nB=\nC=\n'); const res = runCli(cwd, ['--compare', '--only', 'extra']); - expect(res.status).toBe(1); + expect(res.status).toBe(0); expect(res.stdout).toContain('Comparing .env ↔ .env.example'); expect(res.stdout).not.toContain('❌ Missing keys:'); expect(res.stdout).toContain('⚠️ Extra keys (not in example):'); diff --git a/test/e2e/cli.ignore.e2e.test.ts b/test/e2e/cli.ignore.e2e.test.ts index 891ea040..1f380ba9 100644 --- a/test/e2e/cli.ignore.e2e.test.ts +++ b/test/e2e/cli.ignore.e2e.test.ts @@ -33,7 +33,7 @@ describe('--ignore and --ignore-regex', () => { fs.writeFileSync(path.join(cwd, '.env'), 'API_KEY=1\nDEBUG=1\n'); fs.writeFileSync(path.join(cwd, '.env.example'), 'DEBUG=\n'); const res = runCli(cwd, ['--compare']); - expect(res.status).toBe(1); + expect(res.status).toBe(0); expect(res.stdout).toContain('Extra keys'); expect(res.stdout).toContain('API_KEY'); }); diff --git a/test/e2e/cli.scan-usage.e2e.test.ts b/test/e2e/cli.scan-usage.e2e.test.ts index b97b03eb..ad1f982d 100644 --- a/test/e2e/cli.scan-usage.e2e.test.ts +++ b/test/e2e/cli.scan-usage.e2e.test.ts @@ -563,4 +563,21 @@ describe('scan-usage error handling', () => { expect(envContent).toContain('DATABASE_URL='); }); }); -}); + it('will tip --fix flag if missing .env in .gitignore', () => { + const cwd = tmpDir(); + + fs.mkdirSync(path.join(cwd, '.git')); + fs.writeFileSync(path.join(cwd, '.gitignore'), 'node_modules\n'); + fs.writeFileSync(path.join(cwd, '.env'), 'API_KEY=secret\n'); + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src/app.js'), + 'const api = process.env.API_KEY;', + ); + + const res = runCli(cwd, ['--scan-usage']); + console.log('stdout:', res.stdout); + expect(res.status).toBe(0); + expect(res.stdout).toContain('Tip: Run with `--fix` to ensure .env is added to .gitignore'); + }); +}); \ No newline at end of file diff --git a/test/e2e/cli.strict.e2e.test.ts b/test/e2e/cli.strict.e2e.test.ts index 375dfd21..101a9a3c 100644 --- a/test/e2e/cli.strict.e2e.test.ts +++ b/test/e2e/cli.strict.e2e.test.ts @@ -69,23 +69,23 @@ describe('--strict mode', () => { }); describe('--strict mode with --compare', () => { - it('fails on duplicate variables in .env', () => { + it('warns on duplicate variables in .env', () => { const cwd = tmpDir(); fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\nFOO=2\n'); fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\n'); const res = runCli(cwd, ['--strict', '--compare']); - expect(res.status).toBe(1); + expect(res.status).toBe(0); expect(res.stdout).toContain('⚠️ Duplicate keys'); }); - it('fails on duplicate variables in .env.example', () => { + it('warns on duplicate variables in .env.example', () => { const cwd = tmpDir(); fs.writeFileSync(path.join(cwd, '.env'), 'FOO=1\n'); fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=\nFOO=\n'); const res = runCli(cwd, ['--strict', '--compare']); - expect(res.status).toBe(1); + expect(res.status).toBe(0); expect(res.stdout).toContain('⚠️ Duplicate keys in .env.example'); }); }); diff --git a/test/unit/core.fixEnv.test.ts b/test/unit/core.fixEnv.test.ts index 3b539df4..e6bdf5c4 100644 --- a/test/unit/core.fixEnv.test.ts +++ b/test/unit/core.fixEnv.test.ts @@ -102,6 +102,7 @@ describe("applyFixes", () => { expect(result).toEqual({ removedDuplicates: [], addedEnv: [], + gitignoreUpdated: false, addedExample: [], }); });