From 4ef335154ba184407ad5e2feedb1bfe551288fb2 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Sun, 28 Jun 2026 05:18:20 +0000 Subject: [PATCH] fix: print hint when stdin is a TTY to prevent silent hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user runs a command with "-" as the file path from an interactive terminal (e.g. design.md lint -), the process blocks silently waiting for EOF with no indication of what to do. Add a TTY check before the stdin read loop. If stdin is attached to a terminal, write to stderr: Reading from stdin… Press Ctrl+D when done. The stdin stream is accepted as an optional second parameter on readInput (defaulting to process.stdin), making the TTY path fully testable via dependency injection without touching process.stdin directly. Also exports StdinStream so callers can type mock streams without duplicating the definition. --- packages/cli/src/utils.test.ts | 43 +++++++++++++++++++++++++++++++++- packages/cli/src/utils.ts | 11 ++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/utils.test.ts b/packages/cli/src/utils.test.ts index 94fb563..9ef5942 100644 --- a/packages/cli/src/utils.test.ts +++ b/packages/cli/src/utils.test.ts @@ -12,8 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, spyOn } from 'bun:test'; import { readInput, FileReadError, formatOutput } from './utils.js'; +import type { StdinStream } from './utils.js'; + +function makeStdin(content: string, isTTY: boolean): StdinStream { + async function* gen() { yield Buffer.from(content); } + return Object.assign(gen(), { isTTY }); +} describe('readInput', () => { it('throws FileReadError when file does not exist', async () => { @@ -50,6 +56,41 @@ describe('readInput', () => { }); }); +describe('readInput stdin ("-")', () => { + it('reads content from a piped stream', async () => { + const result = await readInput('-', makeStdin('hello world', false)); + expect(result).toBe('hello world'); + }); + + it('returns empty string for an empty piped stream', async () => { + async function* empty() {} + const result = await readInput('-', Object.assign(empty(), { isTTY: false })); + expect(result).toBe(''); + }); + + it('writes a hint to stderr when stdin is a TTY', async () => { + const stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await readInput('-', makeStdin('some content', true)); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Press Ctrl+D') + ); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('does not write a hint when stdin is not a TTY', async () => { + const stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await readInput('-', makeStdin('piped content', false)); + expect(stderrSpy).not.toHaveBeenCalled(); + } finally { + stderrSpy.mockRestore(); + } + }); +}); + describe('formatOutput', () => { describe('--format markdown', () => { it('renders a lint report instead of [object Object]', () => { diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index fac6e64..a6a15b0 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -14,6 +14,8 @@ import { readFileSync } from 'node:fs'; +export type StdinStream = AsyncIterable & { isTTY?: boolean }; + export class FileReadError extends Error { readonly code = 'FILE_READ_ERROR' as const; constructor(public readonly filePath: string, cause: unknown) { @@ -37,11 +39,14 @@ export class FileReadError extends Error { * Read input from a file path or stdin ("-"). * Throws FileReadError if the file cannot be read. */ -export async function readInput(filePath: string): Promise { +export async function readInput(filePath: string, stdin: StdinStream = process.stdin): Promise { if (filePath === '-') { + if (stdin.isTTY) { + process.stderr.write('Reading from stdin… Press Ctrl+D when done.\n'); + } const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer); + for await (const chunk of stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks).toString('utf-8'); }