Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from 'path';
import { normalizeOptions } from '../config/options.js';
import { discoverEnvFiles } from '../services/envDiscovery.js';
import { envPairing } from '../services/envPairing.js';
import { ensureFilesOrPrompt } from '../commands/ensureFilesOrPrompt.js';
import { promptEnsureFiles } from '../commands/prompts/promptEnsureFiles.js';
import { compareMany } from '../commands/compare.js';
import type {
CompareJsonEntry,
Expand Down Expand Up @@ -78,6 +78,7 @@ async function runScanMode(opts: Options): Promise<boolean> {
showUnused: opts.showUnused,
showStats: opts.showStats,
isCiMode: opts.isCiMode,
isYesMode: opts.isYesMode,
secrets: opts.secrets,
strict: opts.strict,
ignoreUrls: opts.ignoreUrls,
Expand Down Expand Up @@ -164,7 +165,7 @@ async function runAutoDiscoveryComparison(opts: Options): Promise<boolean> {
});

// Ensure required files exist or prompt to create them
const initResult = await ensureFilesOrPrompt({
const initResult = await promptEnsureFiles({
cwd: discovery.cwd,
primaryEnv: discovery.primaryEnv,
primaryExample: discovery.primaryExample,
Expand Down Expand Up @@ -211,7 +212,7 @@ async function handleMissingFiles(
return { shouldExit: true, exitWithError: true };
} else {
// Interactive mode - try to prompt for file creation
const result = await ensureFilesOrPrompt({
const result = await promptEnsureFiles({
cwd: opts.cwd,
primaryEnv: envFlag,
primaryExample: exampleFlag,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import fs from 'fs';
import path from 'path';
import { confirmYesNo } from '../ui/prompts.js';
import { warnIfEnvNotIgnored } from '../services/git.js';
import { printPrompt } from '../ui/compare/printPrompt.js';
import { DEFAULT_ENV_FILE } from '../config/constants.js';
import { confirmYesNo } from '../../ui/prompts.js';
import { warnIfEnvNotIgnored } from '../../services/git.js';
import { printPrompt } from '../../ui/compare/printPrompt.js';
import { DEFAULT_ENV_FILE } from '../../config/constants.js';

/**
* Result of ensureFilesOrPrompt function
Expand Down Expand Up @@ -31,7 +31,7 @@ interface EnsureFilesArgs {
* @param args - The arguments for the function.
* @returns An object indicating whether a file was created or if the process should exit.
*/
export async function ensureFilesOrPrompt(
export async function promptEnsureFiles(
args: EnsureFilesArgs,
): Promise<EnsureFilesResult> {
const { cwd, primaryEnv, primaryExample, isYesMode, isCiMode } = args;
Expand Down
43 changes: 43 additions & 0 deletions src/commands/prompts/promptNoEnvScenario.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import fs from 'fs';
import path from 'path';
import { confirmYesNo } from '../../ui/prompts.js';
import { DEFAULT_ENV_FILE } from '../../config/constants.js';
import type { ScanUsageOptions, ComparisonFile } from '../../config/types.js';

/**
* Prompts the user to create a .env file if none are found in scan usage
* @param opts - Scan configuration options
* @returns The path and name of the created .env file, or undefined if none created
*/
export async function promptNoEnvScenario(
opts: ScanUsageOptions,
): Promise<{ compareFile: ComparisonFile | undefined }> {
if (opts.isCiMode) {
return { compareFile: undefined };
}
console.log();
const shouldCreate = opts.isYesMode
? true
: await confirmYesNo(
"You don't have any .env files. Do you want to create a .env?",
{
isCiMode: opts.isCiMode ?? false,
isYesMode: opts.isYesMode ?? false,
},
);

if (!shouldCreate) {
return { compareFile: undefined };
}

const envPath = path.resolve(opts.cwd, DEFAULT_ENV_FILE);

fs.writeFileSync(envPath, '');

return {
compareFile: {
path: envPath,
name: DEFAULT_ENV_FILE,
},
};
}
27 changes: 21 additions & 6 deletions src/commands/scanUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
EnvUsage,
ExitResult,
ScanResult,
ComparisonFile,
} from '../config/types.js';
import { determineComparisonFile } from '../core/scan/determineComparisonFile.js';
import { printScanResult } from '../services/printScanResult.js';
Expand All @@ -15,6 +16,7 @@ import { hasIgnoreComment } from '../core/security/secretDetectors.js';
import { frameworkValidator } from '../core/frameworks/frameworkValidator.js';
import { detectSecretsInExample } from '../core/security/exampleSecretDetector.js';
import { DEFAULT_EXAMPLE_FILE } from '../config/constants.js';
import { promptNoEnvScenario } from './prompts/promptNoEnvScenario.js';

/**
* Scans the codebase for environment variable usage and compares it with
Expand Down Expand Up @@ -62,16 +64,29 @@ export async function scanUsage(opts: ScanUsageOptions): Promise<ExitResult> {
}

// Determine which file to compare against
const compareFile = determineComparisonFile(opts);
const comparisonResolution = await determineComparisonFile(opts);
let comparedAgainst = '';
let duplicatesFound = false;

// Store fix information for consolidated display
let fixApplied = false;
let fixedKeys: string[] = [];
let removedDuplicates: string[] = [];
let gitignoreUpdated = false;

// Handle no env file found scenario
let compareFile: ComparisonFile | undefined;

if (comparisonResolution.type === 'found') {
compareFile = comparisonResolution.file;
} else if (
comparisonResolution.type === 'none' &&
!opts.isCiMode &&
!opts.json
) {
const promptResult = await promptNoEnvScenario(opts);
compareFile = promptResult.compareFile;
}

// 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) {
Expand All @@ -87,9 +102,6 @@ export async function scanUsage(opts: ScanUsageOptions): Promise<ExitResult> {
} else {
scanResult = result.scanResult;
comparedAgainst = result.comparedAgainst;
if (result.duplicatesFound) {
duplicatesFound = result.duplicatesFound;
}
fixApplied = result.fixApplied;
removedDuplicates = result.removedDuplicates;
fixedKeys = result.addedEnv;
Expand All @@ -104,7 +116,10 @@ export async function scanUsage(opts: ScanUsageOptions): Promise<ExitResult> {
scanResult.inconsistentNamingWarnings =
result.inconsistentNamingWarnings;
}
if (result.exampleFull && result.comparedAgainst === DEFAULT_EXAMPLE_FILE) {
if (
result.exampleFull &&
result.comparedAgainst === DEFAULT_EXAMPLE_FILE
) {
scanResult.exampleWarnings = detectSecretsInExample(result.exampleFull);
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export interface ScanUsageOptions extends ScanOptions {
showUnused?: boolean;
showStats?: boolean;
isCiMode?: boolean;
isYesMode?: boolean;
allowDuplicates?: boolean;
strict?: boolean;
uppercaseKeys?: boolean;
Expand Down Expand Up @@ -341,3 +342,11 @@ export interface Discovery {
exampleFlag: string | null;
alreadyWarnedMissingEnv: boolean;
}

/**
* Resolved comparison file with absolute path and display name.
*/
export interface ComparisonFile {
path: string;
name: string;
};
33 changes: 21 additions & 12 deletions src/core/scan/determineComparisonFile.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,57 @@
import fs from 'fs';
import path from 'path';
import type { ScanUsageOptions } from '../../config/types.js';
import type { ScanUsageOptions, ComparisonFile } from '../../config/types.js';
import { resolveFromCwd } from '../helpers/resolveFromCwd.js';
import { DEFAULT_ENV_CANDIDATES } from '../../config/constants.js';
import { normalizePath } from '../helpers/normalizePath.js';

/**
* Resolved comparison file with absolute path and display name.
* Result of determining the comparison file, either found with details or none
*/
type ComparisonFile = {
path: string;
name: string;
};
type ComparisonResolution =
| { type: 'found'; file: ComparisonFile }
| { type: 'none' };

/**
* Determines which file to use for comparison based on provided options
* @param {ScanUsageOptions} opts - Scan configuration options
* @returns Comparison file info with absolute path and basename, or undefined if not found
*/
export function determineComparisonFile(
export async function determineComparisonFile(
opts: ScanUsageOptions,
): ComparisonFile | undefined {
): Promise<ComparisonResolution> {
// Priority: explicit flags first, then auto-discovery

if (opts.examplePath) {
const p = resolveFromCwd(opts.cwd, opts.examplePath);
if (fs.existsSync(p)) {
return { path: p, name: path.basename(opts.examplePath) };
return {
type: 'found',
file: { path: normalizePath(p), name: path.basename(opts.examplePath) },
};
}
}

if (opts.envPath) {
const p = resolveFromCwd(opts.cwd, opts.envPath);
if (fs.existsSync(p)) {
return { path: p, name: path.basename(opts.envPath) };
return {
type: 'found',
file: { path: normalizePath(p), name: path.basename(opts.envPath) },
};
}
}

// Auto-discovery: look for common env files relative to cwd
for (const candidate of DEFAULT_ENV_CANDIDATES) {
const fullPath = path.resolve(opts.cwd, candidate);
if (fs.existsSync(fullPath)) {
return { path: fullPath, name: candidate };
return {
type: 'found',
file: { path: normalizePath(fullPath), name: candidate },
};
}
}

return undefined;
return { type: 'none' };
}
91 changes: 91 additions & 0 deletions test/e2e/cli.autoscan.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ describe('no-flag autoscan', () => {
const cwd = tmpDir();

fs.mkdirSync(path.join(cwd, 'src'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.env'), 'API_KEY=12345\n');
fs.writeFileSync(
path.join(cwd, 'src', 'index.ts'),
`const apiKey = process.env.API_KEY;`,
Expand Down Expand Up @@ -376,3 +377,93 @@ describe('no-flag autoscan', () => {
expect(res.stdout.trim().startsWith('{')).toBe(true);
});
});

describe('It will prompt to ask to create .env file is no .env files are found', () => {
it('should create .env file automatically with --yes flag when none found', () => {
const cwd = tmpDir();

fs.mkdirSync(path.join(cwd, 'src'), { recursive: true });
fs.writeFileSync(
path.join(cwd, 'src', 'index.ts'),
`const apiKey = process.env.API_KEY;`,
);

const res = runCli(cwd, ['--yes']);

expect(res.status).toBe(1); // fails because API_KEY is missing
expect(fs.existsSync(path.join(cwd, '.env'))).toBe(true);
});

it('should not prompt in CI mode when no .env file found', () => {
const cwd = tmpDir();

fs.mkdirSync(path.join(cwd, 'src'), { recursive: true });
fs.writeFileSync(
path.join(cwd, 'src', 'index.ts'),
`const apiKey = process.env.API_KEY;`,
);

const res = runCli(cwd, ['--ci']);

expect(res.status).toBe(0);
expect(res.stdout).not.toContain('Do you want to create a .env?');
expect(res.stdout).not.toContain('Created empty .env');
expect(fs.existsSync(path.join(cwd, '.env'))).toBe(false);
});

it('should not create .env if already exists', () => {
const cwd = tmpDir();

fs.mkdirSync(path.join(cwd, 'src'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.env'), 'EXISTING_KEY=value\n');
fs.writeFileSync(
path.join(cwd, 'src', 'index.ts'),
`const apiKey = process.env.API_KEY;`,
);

const res = runCli(cwd, ['--yes']);

expect(res.status).toBe(1);
expect(res.stdout).not.toContain('Created empty .env');
const envContent = fs.readFileSync(path.join(cwd, '.env'), 'utf-8');
expect(envContent).toBe('EXISTING_KEY=value\n');
});

it('should not create a new .env file when --yes and --json flags are used together', () => {
const cwd = tmpDir();

fs.mkdirSync(path.join(cwd, 'src'), { recursive: true });
fs.writeFileSync(
path.join(cwd, 'src', 'index.ts'),
`const apiKey = process.env.API_KEY;`,
);

const res = runCli(cwd, ['--yes', '--json']);

expect(res.status).toBe(0);
expect(res.stdout).not.toContain('Created empty .env');
expect(fs.existsSync(path.join(cwd, '.env'))).toBe(false);

// Should still output JSON
const lines = res.stdout.split('\n');
const jsonLine = lines.find(line => line.trim().startsWith('{'));
expect(jsonLine).toBeDefined();
});

it('should create .env in correct working directory', () => {
const cwd = tmpDir();
const subdir = path.join(cwd, 'subfolder');

fs.mkdirSync(path.join(subdir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(subdir, 'src', 'index.ts'),
`const apiKey = process.env.API_KEY;`,
);

const res = runCli(subdir, ['--yes']);

expect(res.status).toBe(1); // fails because API_KEY is missing
expect(fs.existsSync(path.join(subdir, '.env'))).toBe(true);
expect(fs.existsSync(path.join(cwd, '.env'))).toBe(false);
});
});
Loading
Loading