diff --git a/.changeset/late-sides-wonder.md b/.changeset/late-sides-wonder.md new file mode 100644 index 00000000..9f013b9d --- /dev/null +++ b/.changeset/late-sides-wonder.md @@ -0,0 +1,5 @@ +--- +'dotenv-diff': patch +--- + +fixed interactive prompt may run in non-TTY environments diff --git a/packages/cli/src/commands/prompts/prompts.ts b/packages/cli/src/commands/prompts/prompts.ts index 647988c3..8f1e4f48 100644 --- a/packages/cli/src/commands/prompts/prompts.ts +++ b/packages/cli/src/commands/prompts/prompts.ts @@ -17,6 +17,13 @@ export async function confirmYesNo( ): Promise { if (isYesMode) return true; if (isCiMode) return false; + + // Avoid hanging in non-interactive environments (e.g. hooks/scripts without TTY) + const hasInteractiveTty = Boolean( + process.stdin?.isTTY && process.stdout?.isTTY, + ); + if (!hasInteractiveTty) return false; + const res = await prompts({ type: 'select', name: 'ok', diff --git a/packages/cli/test/e2e/cli.compare.e2e.test.ts b/packages/cli/test/e2e/cli.compare.e2e.test.ts index ab52b596..27df221a 100644 --- a/packages/cli/test/e2e/cli.compare.e2e.test.ts +++ b/packages/cli/test/e2e/cli.compare.e2e.test.ts @@ -56,7 +56,7 @@ describe('added .env to gitignore with --compare and --fix', () => { fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); fs.writeFileSync( path.join(cwd, 'src', 'index.ts'), - `const apiKey = process.env.API_KEY;`.trimStart(), + 'const apiKey = process.env.API_KEY;'.trimStart(), ); const res = runCli(cwd, ['--compare', '--fix']); @@ -78,7 +78,7 @@ describe('added .env to gitignore with --compare and --fix', () => { fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); fs.writeFileSync( path.join(cwd, 'src', 'index.ts'), - `const apiKey = process.env.API_KEY;`.trimStart(), + 'const apiKey = process.env.API_KEY;'.trimStart(), ); const res = runCli(cwd, ['--compare', '--json']); @@ -89,7 +89,7 @@ describe('added .env to gitignore with --compare and --fix', () => { expect(json[0].gitignoreIssue?.reason).toBe('not-ignored'); }); - it('Will prompt .env.example file not found. and prompt if .env.local exists and .env.example is set to --example', async () => { + it('Will skip interactive prompt in non-TTY when .env.example is missing and --example is set', async () => { const cwd = tmpDir(); fs.writeFileSync(path.join(cwd, '.env.local'), 'FOO=bar'); @@ -97,9 +97,7 @@ describe('added .env to gitignore with --compare and --fix', () => { expect(res.status).toBe(0); expect(res.stdout).toContain('▸ File not found'); - expect(res.stdout).toContain( - 'Do you want to create a .env.example file from .env.local?', - ); + expect(res.stdout).toContain('▸ Skipping .env.example creation'); }); describe('Values mismatch checks', () => { diff --git a/packages/cli/test/unit/commands/prompts/prompts.test.ts b/packages/cli/test/unit/commands/prompts/prompts.test.ts index 02b06115..62d7aa31 100644 --- a/packages/cli/test/unit/commands/prompts/prompts.test.ts +++ b/packages/cli/test/unit/commands/prompts/prompts.test.ts @@ -6,6 +6,17 @@ vi.mock('prompts'); const mockPrompts = prompts as unknown as ReturnType; +function setTTY(stdinTTY: boolean, stdoutTTY: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { + value: stdinTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: stdoutTTY, + configurable: true, + }); +} + describe('confirmYesNo', () => { beforeEach(() => { vi.clearAllMocks(); @@ -32,6 +43,7 @@ describe('confirmYesNo', () => { }); it('returns true when user selects Yes', async () => { + setTTY(true, true); mockPrompts.mockResolvedValue({ ok: true }); const result = await confirmYesNo('Are you sure?', { @@ -44,6 +56,7 @@ describe('confirmYesNo', () => { }); it('returns false when user selects No', async () => { + setTTY(true, true); mockPrompts.mockResolvedValue({ ok: false }); const result = await confirmYesNo('Are you sure?', { @@ -56,6 +69,7 @@ describe('confirmYesNo', () => { }); it('returns false when prompt returns undefined', async () => { + setTTY(true, true); mockPrompts.mockResolvedValue({}); const result = await confirmYesNo('Are you sure?', { @@ -65,4 +79,16 @@ describe('confirmYesNo', () => { expect(result).toBe(false); }); + + it('returns false when no TTY is available and does not prompt', async () => { + setTTY(false, false); + + const result = await confirmYesNo('Are you sure?', { + isCiMode: false, + isYesMode: false, + }); + + expect(result).toBe(false); + expect(mockPrompts).not.toHaveBeenCalled(); + }); });