diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df0819..b3e0ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,15 @@ This project follows [Keep a Changelog](https://keepachangelog.com/) and [Semant - ### Fixed -- +- + +## [2.3.10] - 2025-12-11 +### Added +- More jsDOcs for better code documentation. +- t3-env integration to validate environment variable usage against T3 stack schema. + +### Fixed +- Removed unused code for old --no-compare option. ## [2.3.9] - 2025-12-09 ### Added diff --git a/README.md b/README.md index 8d8d99b..ae4e889 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # dotenv-diff -![Demo](./public/demo.gif) +![Demo](./public/demo2.png) Scan your codebase to detect which environment variables are used in your code. @@ -166,6 +166,22 @@ To disable this behavior, use the `--no-inconsistent-naming-warnings` flags resp "inconsistentNamingWarnings": false ``` +## t3-env integration + +by default `dotenv-diff` will detect if your project uses t3-env and validate environment variable usage against the T3 stack schema. + +This will give you warnings like this: + +```bashT3-env validation issues: + - API_URL (src\index.ts:25) → Variable "API_URL" is not defined in t3-env schema. Add it to either server or client schema. +``` + +To disable this behavior, use the `--no-t3env` flag or set it to false in the config file: + +```bash +"t3env": false +``` + ## Show unused variables As default, `dotenv-diff` will list variables that are defined in `.env` but never used in your codebase. diff --git a/package.json b/package.json index 808ef1b..eed3f6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dotenv-diff", - "version": "2.3.9", + "version": "2.3.10", "type": "module", "description": "Scan your codebase to find environment variables in use.", "bin": { diff --git a/public/demo2.png b/public/demo2.png new file mode 100644 index 0000000..1bb152d Binary files /dev/null and b/public/demo2.png differ diff --git a/src/cli/program.ts b/src/cli/program.ts index 57fb31c..c422a17 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -85,5 +85,10 @@ export function createProgram() { '--no-inconsistent-naming-warnings', 'Disable inconsistent naming pattern warnings', ) + .option('--t3env', 'Warns about specifik Next.js t3env usage patterns') + .option( + '--no-t3env', + 'Disables warnings about Next.js t3env usage patterns', + ) .option('--init', 'Create a sample dotenv-diff.config.json file'); } diff --git a/src/cli/run.ts b/src/cli/run.ts index 8df06c6..3e59275 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -17,6 +17,36 @@ import { printErrorNotFound } from '../ui/compare/printErrorNotFound.js'; import { setupGlobalConfig } from '../ui/shared/setupGlobalConfig.js'; import { loadConfig } from '../config/loadConfig.js'; +/** + * Run the CLI program + * @param program The commander program instance + * @returns void + */ +export async function run(program: Command): Promise { + program.parse(process.argv); + + // Load and normalize options + const cliOptions = program.opts(); + + // Handle --init flag + if (await handleInitFlag(cliOptions)) return; + + // Merge CLI options with config file options + const mergedRawOptions = loadConfig(cliOptions); + + // Normalize merged options + const opts = normalizeOptions(mergedRawOptions); + + setupGlobalConfig(opts); + + // Route to appropriate command + if (opts.compare) { + await runCompareMode(opts); + } else { + await runScanMode(opts); + } +} + /** * Run scan-usage mode (default behavior) * @param opts - Normalized options @@ -41,10 +71,10 @@ async function runScanMode(opts: Options): Promise { secrets: opts.secrets, strict: opts.strict ?? false, ignoreUrls: opts.ignoreUrls ?? [], - noCompare: opts.noCompare ?? false, uppercaseKeys: opts.uppercaseKeys ?? true, expireWarnings: opts.expireWarnings, inconsistentNamingWarnings: opts.inconsistentNamingWarnings, + t3env: opts.t3env, ...(opts.files ? { files: opts.files } : {}), }); @@ -235,33 +265,3 @@ function outputResults( } process.exit(exitWithError ? 1 : 0); } - -/** - * Run the CLI program - * @param program The commander program instance - * @returns void - */ -export async function run(program: Command): Promise { - program.parse(process.argv); - - // Load and normalize options - const cliOptions = program.opts(); - - // Handle --init flag - if (await handleInitFlag(cliOptions)) return; - - // Merge CLI options with config file options - const mergedRawOptions = loadConfig(cliOptions); - - // Normalize merged options - const opts = normalizeOptions(mergedRawOptions); - - setupGlobalConfig(opts); - - // Route to appropriate command - if (opts.compare) { - await runCompareMode(opts); - } else { - await runScanMode(opts); - } -} diff --git a/src/commands/scanUsage.ts b/src/commands/scanUsage.ts index 2b04d6d..cb7d65e 100644 --- a/src/commands/scanUsage.ts +++ b/src/commands/scanUsage.ts @@ -3,6 +3,7 @@ import type { ScanUsageOptions, EnvUsage, ScanResult, + T3EnvWarning, } from '../config/types.js'; import { determineComparisonFile } from '../core/determineComparisonFile.js'; import { outputToConsole } from '../services/scanOutputToConsole.js'; @@ -13,71 +14,8 @@ import { printComparisonError } from '../ui/scan/printComparisonError.js'; import { hasIgnoreComment } from '../core/secretDetectors.js'; import { frameworkValidator } from '../core/frameworkValidator.js'; import { detectSecretsInExample } from '../core/exampleSecretDetector.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[] { - let insideHtmlComment = false; - let insideIgnoreBlock = false; - - return usages.filter((u) => { - if (!u.context) return true; - const line = u.context.trim(); - - if (line.includes('')) { - insideHtmlComment = false; - return false; - } - - if (//i.test(line)) { - insideIgnoreBlock = true; - return false; - } - - if (//i.test(line)) { - insideIgnoreBlock = false; - return false; - } - - if (insideIgnoreBlock) return false; - - return ( - !insideHtmlComment && - !/^\s*(\/\/|#|\/\*|\*|)/.test(line) && - !hasIgnoreComment(line) - ); - }); -} - -/** - * 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, - duration: scanResult.stats.duration, - }; - - return scanResult; -} +import { detectT3Env } from '../core/t3env/detectT3Env.js'; +import { applyT3EnvRules } from '../core/t3env/t3EnvRules.js'; /** * Scans the codebase for environment variable usage and compares it with @@ -125,6 +63,22 @@ export async function scanUsage( scanResult.frameworkWarnings = frameworkWarnings; } + // T3-env validation if t3env option is enabled or auto-detected + if (opts.t3env) { + const t3Detection = await detectT3Env(opts.cwd); + if (t3Detection.detected && t3Detection.schema) { + const t3EnvWarnings: T3EnvWarning[] = []; + + for (const usage of scanResult.used) { + applyT3EnvRules(usage, t3EnvWarnings, t3Detection.schema); + } + + if (t3EnvWarnings.length > 0) { + scanResult.t3EnvWarnings = t3EnvWarnings; + } + } + } + // Determine which file to compare against const compareFile = determineComparisonFile(opts); let envVariables: Record = {}; @@ -208,6 +162,7 @@ export async function scanUsage( (scanResult.secrets?.length ?? 0) > 0)) || (scanResult.exampleWarnings?.length ?? 0) > 0 || (scanResult.frameworkWarnings?.length ?? 0) > 0 || + (scanResult.t3EnvWarnings?.length ?? 0) > 0 || (scanResult.logged?.length ?? 0) > 0 || (scanResult.uppercaseWarnings?.length ?? 0) > 0 || (scanResult.expireWarnings?.length ?? 0) > 0 || @@ -226,3 +181,68 @@ export async function scanUsage( return { exitWithError: result.exitWithError || duplicatesFound }; } + +/** + * 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[] { + let insideHtmlComment = false; + let insideIgnoreBlock = false; + + return usages.filter((u) => { + if (!u.context) return true; + const line = u.context.trim(); + + if (line.includes('')) { + insideHtmlComment = false; + return false; + } + + if (//i.test(line)) { + insideIgnoreBlock = true; + return false; + } + + if (//i.test(line)) { + insideIgnoreBlock = false; + return false; + } + + if (insideIgnoreBlock) return false; + + return ( + !insideHtmlComment && + !/^\s*(\/\/|#|\/\*|\*|)/.test(line) && + !hasIgnoreComment(line) + ); + }); +} + +/** + * 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, + duration: scanResult.stats.duration, + }; + + return scanResult; +} diff --git a/src/config/options.ts b/src/config/options.ts index e6e0cac..4330695 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -96,12 +96,11 @@ export function normalizeOptions(raw: RawOptions): Options { const excludeFiles = parseList(raw.excludeFiles); const files = parseList(raw.files); - const noCompare = toBool(raw.noCompare); - const ignoreUrls = parseList(raw.ignoreUrls); const uppercaseKeys = raw.uppercaseKeys !== false; const expireWarnings = raw.expireWarnings !== false; const inconsistentNamingWarnings = raw.inconsistentNamingWarnings !== false; + const t3env = raw.t3env !== false; const cwd = process.cwd(); const envFlag = @@ -139,9 +138,9 @@ export function normalizeOptions(raw: RawOptions): Options { secrets, strict, ignoreUrls, - noCompare, uppercaseKeys, expireWarnings, inconsistentNamingWarnings, + t3env, }; } diff --git a/src/config/types.ts b/src/config/types.ts index f7cb596..feb03f7 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -51,10 +51,10 @@ export type Options = { secrets: boolean; strict: boolean | undefined; ignoreUrls?: string[]; - noCompare: boolean; uppercaseKeys: boolean; expireWarnings: boolean; inconsistentNamingWarnings: boolean; + t3env: boolean; }; /** Type representing the raw options for the comparison @@ -83,11 +83,11 @@ export type RawOptions = { secrets?: boolean; strict?: boolean; ignoreUrls?: string[]; - noCompare?: boolean; init?: boolean; uppercaseKeys?: boolean; expireWarnings?: boolean; inconsistentNamingWarnings?: boolean; + t3env?: boolean; }; /** @@ -139,7 +139,6 @@ export interface ScanOptions { files?: string[]; secrets?: boolean; ignoreUrls?: string[]; - noCompare?: boolean; } export interface ScanResult { @@ -163,6 +162,7 @@ export interface ScanResult { uppercaseWarnings?: UppercaseWarning[]; expireWarnings?: ExpireWarning[]; inconsistentNamingWarnings?: InconsistentNamingWarning[]; + t3EnvWarnings?: T3EnvWarning[]; } /** Options for scanning the codebase for environment variable usage. */ @@ -180,6 +180,7 @@ export interface ScanUsageOptions extends ScanOptions { uppercaseKeys?: boolean; expireWarnings?: boolean; inconsistentNamingWarnings?: boolean; + t3env?: boolean; } export interface ScanJsonEntry { @@ -239,6 +240,12 @@ export interface ScanJsonEntry { key2: string; suggestion: string; }>; + t3EnvWarnings?: Array<{ + variable: string; + reason: string; + file: string; + line: number; + }>; } // Type for grouped usages by variable @@ -313,3 +320,32 @@ export interface InconsistentNamingWarning { key2: string; suggestion: string; } + +/** + * Schema structure for t3-env configuration + */ +export interface T3EnvSchema { + server: string[]; + client: string[]; +} + +/** + * Result of t3-env detection + */ +export interface T3EnvDetectionResult { + detected: boolean; + schema?: T3EnvSchema; + detectionMethod: 'config' | 'package.json' | null; + configPath?: string; +} + +/** + * Warning for t3-env validation issues + */ +export interface T3EnvWarning { + variable: string; + reason: string; + file: string; + line: number; + framework: 't3-env'; +} diff --git a/src/core/computeHealthScore.ts b/src/core/computeHealthScore.ts index 348e6c0..25cba25 100644 --- a/src/core/computeHealthScore.ts +++ b/src/core/computeHealthScore.ts @@ -36,6 +36,12 @@ export function computeHealthScore(scan: ScanResult): number { // === 8. Expiration warnings === score -= (scan.expireWarnings?.length ?? 0) * 5; + // === 9. Inconsistent naming warnings === + score -= (scan.inconsistentNamingWarnings?.length ?? 0) * 3; + + // === 10. T3-env warnings === + score -= (scan.t3EnvWarnings?.length ?? 0) * 5; + // Never go below 0 or above 100 return Math.max(0, Math.min(100, score)); } diff --git a/src/core/determineComparisonFile.ts b/src/core/determineComparisonFile.ts index 6312537..7518e01 100644 --- a/src/core/determineComparisonFile.ts +++ b/src/core/determineComparisonFile.ts @@ -13,9 +13,6 @@ export function determineComparisonFile( ): { path: string; name: string } | undefined { // Priority: explicit flags first, then auto-discovery - if (opts.noCompare) { - return undefined; - } if (opts.examplePath) { const p = resolveFromCwd(opts.cwd, opts.examplePath); if (fs.existsSync(p)) { diff --git a/src/core/helpers/isAllOk.ts b/src/core/helpers/isAllOk.ts index b592312..df83fbb 100644 --- a/src/core/helpers/isAllOk.ts +++ b/src/core/helpers/isAllOk.ts @@ -2,6 +2,7 @@ import type { Filtered } from '../../config/types.js'; /** * Checks if all filtered comparison results are okay (i.e., no issues found). + * This is used in compare.ts to determine if the comparison passed all checks. * @param filtered - The filtered comparison results. * @returns True if all checks pass, false otherwise. */ diff --git a/src/core/scanJsonOutput.ts b/src/core/scanJsonOutput.ts index 226f177..1f62610 100644 --- a/src/core/scanJsonOutput.ts +++ b/src/core/scanJsonOutput.ts @@ -67,6 +67,15 @@ export function createJsonOutput( })); } + if (scanResult.t3EnvWarnings?.length) { + output.t3EnvWarnings = scanResult.t3EnvWarnings.map((w) => ({ + variable: w.variable, + reason: w.reason, + file: w.file, + line: w.line, + })); + } + // Add duplicates if found if (scanResult.duplicates) { output.duplicates = scanResult.duplicates; diff --git a/src/core/t3env/detectT3Env.ts b/src/core/t3env/detectT3Env.ts new file mode 100644 index 0000000..1e61390 --- /dev/null +++ b/src/core/t3env/detectT3Env.ts @@ -0,0 +1,136 @@ +import fs from 'fs'; +import path from 'path'; +import type { T3EnvDetectionResult, T3EnvSchema } from '../../config/types.js'; + +/** + * Detects if a project uses t3-env by looking for env.ts files + * @param cwd - Current working directory to scan + * @returns Detection result with schema if found + */ +export async function detectT3Env(cwd: string): Promise { + const hasT3EnvDependency = await checkPackageJson(cwd); + + if (!hasT3EnvDependency) { + return { + detected: false, + detectionMethod: null, + }; + } + + // Check common locations for env config files + const envFilePaths = [ + 'src/env.ts', + 'src/env.mjs', + 'src/env.js', + 'env.ts', + 'env.mjs', + 'env.js', + 'lib/env.ts', + 'lib/env.mjs', + 'lib/env.js', + ]; + + for (const envPath of envFilePaths) { + const fullPath = path.join(cwd, envPath); + + if (fs.existsSync(fullPath)) { + const content = fs.readFileSync(fullPath, 'utf8'); + + // Check if file contains t3-env usage + if (content.includes('createEnv')) { + const schema = parseT3EnvFromContent(content); + if (schema) { + return { + detected: true, + schema, + detectionMethod: 'config', + configPath: envPath, + }; + } + } + } + } + + return { + detected: true, + schema: { server: [], client: [] }, + detectionMethod: 'package.json', + }; +} + +/** + * Parses t3-env schema from file content + */ +export function parseT3EnvFromContent(content: string): T3EnvSchema | null { + try { + // Find server and client schema sections + const serverMatch = content.match( + /server\s*:\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/s, + ); + const clientMatch = content.match( + /client\s*:\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/s, + ); + + if (!serverMatch && !clientMatch) { + return null; + } + + const serverKeys = + serverMatch && serverMatch[1] + ? extractKeysFromSchema(serverMatch[1]) + : []; + const clientKeys = + clientMatch && clientMatch[1] + ? extractKeysFromSchema(clientMatch[1]) + : []; + + return { + server: serverKeys, + client: clientKeys, + }; + } catch { + return null; + } +} + +/** + * Extracts variable keys from schema block + */ +function extractKeysFromSchema(schemaBlock: string): string[] { + const keys: string[] = []; + + // Match patterns like: VARIABLE_NAME: z.string() + const keyPattern = /([A-Z_][A-Z0-9_]*)\s*:/g; + let match; + + while ((match = keyPattern.exec(schemaBlock)) !== null) { + if (match[1]) { + keys.push(match[1]); + } + } + + return keys; +} + +/** + * Checks if t3-env is listed in package.json dependencies + */ +async function checkPackageJson(cwd: string): Promise { + const packageJsonPath = path.join(cwd, 'package.json'); + + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const allDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + + return !!(allDeps['@t3-oss/env-core'] || allDeps['@t3-oss/env-nextjs']); + } catch { + return false; + } +} diff --git a/src/core/t3env/t3EnvRules.ts b/src/core/t3env/t3EnvRules.ts new file mode 100644 index 0000000..8d2f505 --- /dev/null +++ b/src/core/t3env/t3EnvRules.ts @@ -0,0 +1,88 @@ +import type { EnvUsage, T3EnvSchema } from '../../config/types.js'; +import type { T3EnvWarning } from '../../config/types.js'; + +/** + * Applies t3-env specific validation rules + * @param u - The environment variable usage information + * @param warnings - The array to push warnings into + * @param schema - The t3-env schema to validate against + */ +export function applyT3EnvRules( + u: EnvUsage, + warnings: T3EnvWarning[], + schema: T3EnvSchema, +): void { + const allServerVars = schema.server; + const allClientVars = schema.client; + + // Check if variable is used in client code but not defined in client schema + const isClientContext = + u.file.includes('/components/') || + u.file.includes('/pages/') || + u.file.includes('/app/') || + u.file.includes('client') || + u.file.includes('browser') || + u.pattern === 'import.meta.env'; + + const isServerContext = + u.file.includes('/api/') || + u.file.includes('server') || + u.file.endsWith('.server.ts') || + u.file.endsWith('.server.js') || + u.pattern === 'process.env'; + + // Variable used in client context but only defined in server schema + if ( + isClientContext && + allServerVars.includes(u.variable) && + !allClientVars.includes(u.variable) + ) { + warnings.push({ + variable: u.variable, + reason: `Variable "${u.variable}" is used in client code but only defined in server schema. Add to client schema or move to server-only code.`, + file: u.file, + line: u.line, + framework: 't3-env', + }); + } + + // Variable used in server context but only defined in client schema + if ( + isServerContext && + allClientVars.includes(u.variable) && + !allServerVars.includes(u.variable) + ) { + warnings.push({ + variable: u.variable, + reason: `Variable "${u.variable}" is used in server code but only defined in client schema. This may expose client variables on the server.`, + file: u.file, + line: u.line, + framework: 't3-env', + }); + } + + // Variable not defined in any schema + if ( + !allServerVars.includes(u.variable) && + !allClientVars.includes(u.variable) + ) { + warnings.push({ + variable: u.variable, + reason: `Variable "${u.variable}" is not defined in t3-env schema. Add it to either server or client schema.`, + file: u.file, + line: u.line, + framework: 't3-env', + }); + } + + // Warn about NEXT_PUBLIC_ variables in t3-env projects + if (u.variable.startsWith('NEXT_PUBLIC_')) { + warnings.push({ + variable: u.variable, + reason: `Use t3-env client schema instead of NEXT_PUBLIC_ prefix for type-safe environment variables.`, + file: u.file, + line: u.line, + framework: 't3-env', + }); + } +} diff --git a/src/services/scanOutputToConsole.ts b/src/services/scanOutputToConsole.ts index 1115112..e95be8a 100644 --- a/src/services/scanOutputToConsole.ts +++ b/src/services/scanOutputToConsole.ts @@ -15,6 +15,7 @@ import { printStrictModeError } from '../ui/shared/printStrictModeError.js'; import { printFixTips } from '../ui/shared/printFixTips.js'; import { printAutoFix } from '../ui/shared/printAutoFix.js'; import { printFrameworkWarnings } from '../ui/scan/printFrameworkWarnings.js'; +import { printT3EnvWarnings } from '../ui/scan/printT3EnvWarnings.js'; import { printExampleWarnings } from '../ui/scan/printExampleWarnings.js'; import { printConsolelogWarning } from '../ui/scan/printConsolelogWarning.js'; import { printUppercaseWarning } from '../ui/scan/printUppercaseWarning.js'; @@ -79,6 +80,10 @@ export function outputToConsole( printFrameworkWarnings(scanResult.frameworkWarnings, isJson); } + if (scanResult.t3EnvWarnings && scanResult.t3EnvWarnings.length > 0) { + printT3EnvWarnings(scanResult.t3EnvWarnings, isJson); + } + if (scanResult.uppercaseWarnings && scanResult.uppercaseWarnings.length > 0) { printUppercaseWarning( scanResult.uppercaseWarnings, @@ -189,6 +194,7 @@ export function outputToConsole( expireWarnings: scanResult.expireWarnings?.length ?? 0, inconsistentNamingWarnings: scanResult.inconsistentNamingWarnings?.length ?? 0, + t3EnvWarnings: scanResult.t3EnvWarnings?.length ?? 0, }, isJson, ); diff --git a/src/ui/scan/printT3EnvWarnings.ts b/src/ui/scan/printT3EnvWarnings.ts new file mode 100644 index 0000000..9ae1d33 --- /dev/null +++ b/src/ui/scan/printT3EnvWarnings.ts @@ -0,0 +1,29 @@ +import chalk from 'chalk'; +import type { T3EnvWarning } from '../../config/types.js'; + +/** + * Prints t3-env validation warnings to the console. + * @param warnings - List of t3-env validation warnings + * @param json - Whether to output in JSON format + */ +export function printT3EnvWarnings( + warnings: T3EnvWarning[], + json: boolean, +): void { + if (!warnings || warnings.length === 0) return; + + if (json) { + console.log(JSON.stringify({ t3EnvWarnings: warnings }, null, 2)); + return; + } + + console.log(chalk.yellow('⚠️ T3-env validation issues:')); + + for (const w of warnings) { + console.log( + chalk.yellow(` - ${w.variable} (${w.file}:${w.line}) → ${w.reason}`), + ); + } + + console.log(); +} diff --git a/src/ui/shared/printStrictModeError.ts b/src/ui/shared/printStrictModeError.ts index a91162d..9b5c04e 100644 --- a/src/ui/shared/printStrictModeError.ts +++ b/src/ui/shared/printStrictModeError.ts @@ -12,6 +12,7 @@ interface StrictModeContext { uppercaseWarnings?: number; expireWarnings?: number; inconsistentNamingWarnings?: number; + t3EnvWarnings?: number; } /** @@ -43,6 +44,8 @@ export function printStrictModeError( warnings.push('expired environment variables'); if (ctx.inconsistentNamingWarnings && ctx.inconsistentNamingWarnings > 0) warnings.push('inconsistent naming patterns'); + if (ctx.t3EnvWarnings && ctx.t3EnvWarnings > 0) + warnings.push('T3 stack environment variable issues'); if (warnings.length === 0) return false; diff --git a/test/e2e/cli.t3-env.e2e.test.ts b/test/e2e/cli.t3-env.e2e.test.ts new file mode 100644 index 0000000..b6c1405 --- /dev/null +++ b/test/e2e/cli.t3-env.e2e.test.ts @@ -0,0 +1,276 @@ +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; +} + +/** + * Minimal t3-env project setup + */ +function makeT3EnvProject(cwd: string) { + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + + fs.writeFileSync( + path.join(cwd, 'package.json'), + JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "@t3-oss/env-nextjs": "^0.7.0", + "zod": "^3.22.0" + } + }, null, 2) + ); + + fs.writeFileSync( + path.join(cwd, 'src', 'env.ts'), + `import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + DATABASE_URL: z.string(), + SECRET_KEY: z.string(), + ADMIN_EMAIL: z.string(), + }, + client: { + NEXT_PUBLIC_API_URL: z.string(), + NEXT_PUBLIC_SITE_NAME: z.string(), + }, + runtimeEnv: { + DATABASE_URL: process.env.DATABASE_URL, + SECRET_KEY: process.env.SECRET_KEY, + ADMIN_EMAIL: process.env.ADMIN_EMAIL, + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + NEXT_PUBLIC_SITE_NAME: process.env.NEXT_PUBLIC_SITE_NAME, + }, +});` + ); + + fs.writeFileSync( + path.join(cwd, '.env'), + `DATABASE_URL=postgresql://localhost:5432/db +SECRET_KEY=secret123 +ADMIN_EMAIL=admin@test.com +NEXT_PUBLIC_API_URL=https://api.test.com +NEXT_PUBLIC_SITE_NAME=Test Site` + ); +} + +describe('t3-env warnings', () => { + it('should detect server variable used in client code', () => { + const cwd = tmpDir(); + makeT3EnvProject(cwd); + + fs.mkdirSync(path.join(cwd, 'src', 'components'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'components', 'ClientComponent.tsx'), + `import React from 'react'; + +export function ClientComponent() { + // Should warn: using server variable in client code + const dbUrl = process.env.DATABASE_URL; + + return
Client Component
; +}` + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('T3-env validation issues'); + expect(res.stdout).toContain('DATABASE_URL'); + expect(res.stdout).toContain('Use t3-env client schema instead of NEXT_PUBLIC_ prefix for type-safe environment variables.'); + }); + + it('should detect client variable used in server code', () => { + const cwd = tmpDir(); + makeT3EnvProject(cwd); + + fs.mkdirSync(path.join(cwd, 'src', 'pages', 'api'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'pages', 'api', 'users.ts'), + `import { NextApiRequest, NextApiResponse } from 'next'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + // Should warn: using client variable in server code + const siteName = process.env.NEXT_PUBLIC_SITE_NAME; + + res.json({ success: true }); +}` + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('T3-env validation issues'); + expect(res.stdout).toContain('NEXT_PUBLIC_SITE_NAME'); + expect(res.stdout).toContain('Use t3-env client schema instead of NEXT_PUBLIC_ prefix for type-safe environment variables.'); + }); + + it('should detect undefined variables not in schema', () => { + const cwd = tmpDir(); + makeT3EnvProject(cwd); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'test.js'), + `const unknownVar = process.env.UNDEFINED_VAR;` + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(1); + expect(res.stdout).toContain('T3-env validation issues'); + expect(res.stdout).toContain('UNDEFINED_VAR'); + expect(res.stdout).toContain('Variable "UNDEFINED_VAR" is not defined in t3-env schema. Add it to either server or client schema.'); + }); + + it('should warn about NEXT_PUBLIC_ usage in t3-env projects', () => { + const cwd = tmpDir(); + makeT3EnvProject(cwd); + + fs.mkdirSync(path.join(cwd, 'src', 'lib'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'lib', 'client.ts'), + `export const config = { + apiUrl: process.env.NEXT_PUBLIC_OLD_API_URL, +};` + ); + + const res = runCli(cwd, ['--scan-usage', '--t3env']); + + expect(res.status).toBe(1); + expect(res.stdout).toContain('T3-env validation issues'); + expect(res.stdout).toContain('NEXT_PUBLIC_OLD_API_URL'); + expect(res.stdout).toContain('is not defined in t3-env schema. Add it to either server or client schema.'); + }); + + it('should not show t3-env warnings when t3env flag is disabled', () => { + const cwd = tmpDir(); + makeT3EnvProject(cwd); + + fs.writeFileSync( + path.join(cwd, 'src', 'test.js'), + `const key = process.env.UNDEFINED_VAR;` + ); + + const res = runCli(cwd, ['--scan-usage', '--no-t3env']); + + expect(res.stdout).not.toContain('T3-env validation issues'); + expect(res.stdout).not.toContain('not defined in t3-env schema'); + }); + + it('should work with env.mjs files', () => { + const cwd = tmpDir(); + + fs.writeFileSync( + path.join(cwd, 'package.json'), + JSON.stringify({ + name: "test-project", + version: "1.0.0", + dependencies: { + "@t3-oss/env-nextjs": "^0.7.0", + "zod": "^3.22.0" + } + }, null, 2) + ); + + fs.writeFileSync( + path.join(cwd, 'env.mjs'), + `import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + DATABASE_URL: z.string(), + }, + client: { + NEXT_PUBLIC_APP_URL: z.string(), + }, +});` + ); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'test.js'), + `// Should warn: server var in client context +const dbUrl = process.env.DATABASE_UPSURL;` + ); + + fs.writeFileSync(path.join(cwd, '.env'), 'DATABASE_URL=test'); + + const res = runCli(cwd, ['--scan-usage', '--t3env']); + console.log(res.stdout); + expect(res.status).toBe(1); + expect(res.stdout).toContain('T3-env validation issues'); + expect(res.stdout).toContain('DATABASE_URL'); + expect(res.stdout).toContain('Variable "DATABASE_UPSURL" is not defined in t3-env schema. Add it to either server or client schema.'); + }); + + it('should handle JSON output format', () => { + const cwd = tmpDir(); + makeT3EnvProject(cwd); + + fs.writeFileSync( + path.join(cwd, 'test.js'), + `const test = process.env.UNDEFINED_VAR;` + ); + + const res = runCli(cwd, ['--json']); + expect(res.status).toBe(1); + const output = JSON.parse(res.stdout); + expect(output.t3EnvWarnings).toBeDefined(); + expect(output.t3EnvWarnings.length).toBeGreaterThan(0); + }); + + it('should detect when no t3-env config exists', () => { + const cwd = tmpDir(); + + // No env.ts file - should not detect t3-env + fs.writeFileSync( + path.join(cwd, 'test.js'), + `const test = process.env.ANY_VAR;` + ); + + fs.writeFileSync(path.join(cwd, '.env'), ''); + + const res = runCli(cwd, ['--scan-usage', '--t3env']); + + expect(res.stdout).not.toContain('T3-env validation issues'); + }); + + it('will fail on strict mode when t3-env warnings exist', () => { + const cwd = tmpDir(); + makeT3EnvProject(cwd); + + const res = runCli(cwd, ['--scan-usage', '--t3env', '--strict']); + console.log(res.stdout); + expect(res.status).toBe(1); + expect(res.stdout).toContain('T3-env validation issues'); + }); +}); +