diff --git a/.changeset/fix-example-monkey.md b/.changeset/fix-example-monkey.md new file mode 100644 index 00000000..90771087 --- /dev/null +++ b/.changeset/fix-example-monkey.md @@ -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) diff --git a/docs/configuration_and_flags.md b/docs/configuration_and_flags.md index e88cd36f..15e9a3a3 100644 --- a/docs/configuration_and_flags.md +++ b/docs/configuration_and_flags.md @@ -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: diff --git a/docs/index.md b/docs/index.md index 3aabcdd7..29442fa5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 7cab3054..26ed8e55 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -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 diff --git a/packages/cli/src/commands/prompts/promptEnsureFiles.ts b/packages/cli/src/commands/prompts/promptEnsureFiles.ts index bfb2aee0..b0933ac2 100644 --- a/packages/cli/src/commands/prompts/promptEnsureFiles.ts +++ b/packages/cli/src/commands/prompts/promptEnsureFiles.ts @@ -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 @@ -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; +} diff --git a/packages/cli/test/unit/commands/prompts/promptEnsureFiles.test.ts b/packages/cli/test/unit/commands/prompts/promptEnsureFiles.test.ts index 9c68b885..45d21a73 100644 --- a/packages/cli/test/unit/commands/prompts/promptEnsureFiles.test.ts +++ b/packages/cli/test/unit/commands/prompts/promptEnsureFiles.test.ts @@ -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', () => ({ @@ -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, @@ -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, @@ -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, @@ -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, @@ -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'); + }); +});