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
5 changes: 5 additions & 0 deletions .changeset/fix-example-monkey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': patch
---

fix: auto-generated `.env.example` no longer copies non-key lines, such as the body of a multi-line quoted value (e.g. a PEM private key)
4 changes: 2 additions & 2 deletions docs/configuration_and_flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,8 @@ Usage in the configuration file:

### `--ci`

Run in CI mode: non-interactive and read-only.
Prevents all file creation and modification, including prompts for missing files.
Run in CI mode: non-interactive.
Disables prompts and never creates missing files. Files are only modified if you also pass [`--fix`](#--fix) explicitly.
Always exits with an error if issues are found. Recommended for CI/CD pipelines.

Example usage:
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ npx dotenv-diff
```

If no `.env` files are found, `dotenv-diff` will prompt you to create one (interactive mode only).
Use `--yes` to auto-accept prompts, or `--ci` to disable prompts and run read-only.
Use `--yes` to auto-accept prompts, or `--ci` to disable prompts and never create missing files.
If you choose not to create a file, `dotenv-diff` will still scan your codebase for environment variable usage, but it will not compare results against an `.env` file.


Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ export function createProgram() {
'--baseline',
'Set current codebase state as baseline for future comparisons',
)
.option('--ci', 'Run non-interactively and never create files')
.option(
'--ci',
'Run non-interactively and never create missing files (--fix still applies)',
)
.option('-y, --yes', 'Run non-interactively and answer Yes to prompts')

// Output
Expand Down
64 changes: 52 additions & 12 deletions packages/cli/src/commands/prompts/promptEnsureFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { confirmYesNo } from './prompts.js';
import { warnIfEnvNotIgnored } from '../../services/git.js';
import { printPrompt } from '../../ui/compare/printPrompt.js';
import { DEFAULT_ENV_FILE } from '../../config/constants.js';
import { parseEnvLine, splitEnvLines } from '../../core/envLine.js';

/**
* Result of ensureFilesOrPrompt function
Expand Down Expand Up @@ -121,20 +122,59 @@ export async function promptEnsureFiles(
return EXIT_WITHOUT_CREATING;
}

const envContent = fs
.readFileSync(envPath, 'utf-8')
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return trimmed;
const [key] = trimmed.split('=');
return `${key}=`;
})
.join('\n');

fs.writeFileSync(examplePath, envContent);
const exampleContent = toExampleContent(fs.readFileSync(envPath, 'utf-8'));

fs.writeFileSync(examplePath, exampleContent);
printPrompt.exampleCreated(examplePath, envPath);
}

return { didCreate: true, shouldExit: false, exitCode: 0 };
}

/**
* Builds .env.example content from .env content. Keeps comments and blank
* lines, strips every value, and drops anything that is not a `KEY=value`
* line, such as the body of a multi-line quoted value (fx a PEM key), so no
* secret material ends up in a file meant to be committed.
* @param envContent - Raw content of the .env file
* @returns Content for the .env.example file
*/
export function toExampleContent(envContent: string): string {
const lines: string[] = [];
let openQuote: string | null = null;

for (const line of splitEnvLines(envContent)) {
const trimmed = line.trim();

// Inside a multi-line quoted value: skip until the closing quote
if (openQuote) {
if (trimmed.includes(openQuote)) openQuote = null;
continue;
}

if (!trimmed || trimmed.startsWith('#')) {
lines.push(trimmed);
continue;
}

const parsed = parseEnvLine(line);
if (!parsed) continue;

lines.push(`${parsed.key}=`);
openQuote = unclosedQuote(parsed.value);
}

return lines.join('\n');
}

/**
* Returns the quote character a value opens without closing on the same line.
* fx: `"-----BEGIN KEY-----` → `"`, `"closed"` → null, `plain` → null
* @param value - The value part of a dotenv line
* @returns The unclosed quote character, or null
*/
function unclosedQuote(value: string): string | null {
const quote = value[0];
if (quote !== '"' && quote !== "'" && quote !== '`') return null;
return value.indexOf(quote, 1) === -1 ? quote : null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { promptEnsureFiles } from '../../../../src/commands/prompts/promptEnsureFiles.js';
import {
promptEnsureFiles,
toExampleContent,
} from '../../../../src/commands/prompts/promptEnsureFiles.js';

// ---- mocks ----
vi.mock('../../../../src/commands/prompts/prompts.js', () => ({
Expand Down Expand Up @@ -41,7 +44,7 @@ describe('promptEnsureFiles', () => {
it('includes Do you want to create message in the confirmYesNo prompt text', async () => {
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=bar');

(confirmYesNo as any).mockResolvedValue(true);
vi.mocked(confirmYesNo).mockResolvedValue(true);

await promptEnsureFiles({
cwd,
Expand Down Expand Up @@ -78,7 +81,7 @@ describe('promptEnsureFiles', () => {
it('does not create .env when user declines', async () => {
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=bar');

(confirmYesNo as any).mockResolvedValue(false);
vi.mocked(confirmYesNo).mockResolvedValue(false);

const result = await promptEnsureFiles({
cwd,
Expand All @@ -96,7 +99,7 @@ describe('promptEnsureFiles', () => {
it('creates .env when user confirms via interactive prompt', async () => {
fs.writeFileSync(path.join(cwd, '.env.example'), 'FOO=bar');

(confirmYesNo as any).mockResolvedValue(true);
vi.mocked(confirmYesNo).mockResolvedValue(true);

const result = await promptEnsureFiles({
cwd,
Expand Down Expand Up @@ -136,7 +139,7 @@ describe('promptEnsureFiles', () => {
it('skips creating .env.example when user declines', async () => {
fs.writeFileSync(path.join(cwd, '.env'), 'FOO=bar');

(confirmYesNo as any).mockResolvedValue(false);
vi.mocked(confirmYesNo).mockResolvedValue(false);

const result = await promptEnsureFiles({
cwd,
Expand Down Expand Up @@ -277,3 +280,46 @@ BAZ=123
expect(result.shouldExit).toBe(false);
});
});

describe('toExampleContent', () => {
it('keeps comments and blank lines and strips values', () => {
expect(toExampleContent('# db\nFOO=bar\n\nBAZ=123\n')).toBe(
'# db\nFOO=\n\nBAZ=\n',
);
});

it('never copies the body of a multi-line quoted value', () => {
const env = [
'API=1',
'PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----',
'MIIEsecretBODYline',
'abcd==',
'-----END RSA PRIVATE KEY-----"',
'AFTER=2',
].join('\n');

expect(toExampleContent(env)).toBe('API=\nPRIVATE_KEY=\nAFTER=');
});

it('handles single quotes and a trailing comment after the closing quote', () => {
const env = "CERT='line1\nline2' # cert\nNEXT=1";

expect(toExampleContent(env)).toBe('CERT=\nNEXT=');
});

it('treats a value that opens and closes its quote on one line as complete', () => {
expect(toExampleContent('A="x"\nB=\'y\' # c\nC=`z`\nD=1')).toBe(
'A=\nB=\nC=\nD=',
);
});

it('drops lines that are not KEY=value', () => {
expect(toExampleContent('FOO=1\njust some text\n=nokey\nBAR=2')).toBe(
'FOO=\nBAR=',
);
});

it('handles CRLF line endings and a BOM', () => {
expect(toExampleContent('\uFEFFFOO=1\r\nBAR=2\r\n')).toBe('FOO=\nBAR=\n');
});
});
Loading