diff --git a/src/lib/prompt.test.ts b/src/lib/prompt.test.ts index a552882..45275e3 100644 --- a/src/lib/prompt.test.ts +++ b/src/lib/prompt.test.ts @@ -39,6 +39,31 @@ describe('promptText', () => { expect(await promptText('? ', { input, output })).toBe('ok'); }); + it('preserves buffered answers for sequential prompts on the same stream', async () => { + const input = Readable.from(['first\nsecond\nthird\n']); + const output = new CaptureStream(); + + await expect(promptText('One: ', { input, output })).resolves.toBe('first'); + await expect(promptText('Two: ', { input, output })).resolves.toBe('second'); + await expect(promptText('Three: ', { input, output })).resolves.toBe('third'); + }); + + it('uses buffered tail input at EOF for a following prompt', async () => { + const input = Readable.from(['first\nsecond']); + const output = new CaptureStream(); + + await expect(promptText('One: ', { input, output })).resolves.toBe('first'); + await expect(promptText('Two: ', { input, output })).resolves.toBe('second'); + }); + + it('preserves buffered CRLF answers for sequential prompts', async () => { + const input = Readable.from(['first\r\nsecond\r\n']); + const output = new CaptureStream(); + + await expect(promptText('One: ', { input, output })).resolves.toBe('first'); + await expect(promptText('Two: ', { input, output })).resolves.toBe('second'); + }); + it('returns the buffered input on stream end without newline', async () => { const input = Readable.from(['eof-no-newline']); const output = new CaptureStream(); @@ -70,6 +95,16 @@ describe('promptSecret (non-TTY behavior)', () => { expect(await promptSecret('? ', { input, output })).toBe('abd'); }); + it('preserves buffered secret answers for sequential prompts', async () => { + const input = Readable.from(['sk-one\nsk-two\n']); + const output = new CaptureStream(); + + await expect(promptSecret('First key: ', { input, output })).resolves.toBe('sk-one'); + await expect(promptSecret('Second key: ', { input, output })).resolves.toBe('sk-two'); + expect(output.text()).not.toContain('sk-one'); + expect(output.text()).not.toContain('sk-two'); + }); + it('rejects on Ctrl-C input', async () => { const ETX = String.fromCharCode(0x03); const input = Readable.from([`abc${ETX}`]); diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts index 96fca29..3561b0a 100644 --- a/src/lib/prompt.ts +++ b/src/lib/prompt.ts @@ -11,6 +11,8 @@ interface RawModeCapable { resume?: () => unknown; } +const pendingPromptInput = new WeakMap(); + export async function promptText(question: string, streams: PromptStreams = {}): Promise { const input = streams.input ?? process.stdin; const output = streams.output ?? process.stdout; @@ -41,18 +43,29 @@ function readLine( return new Promise((resolve, reject) => { let buffer = ''; let resolved = false; + let listening = false; const onData = (chunk: Buffer | string): void => { const str = typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); - for (const ch of str) { - const code = ch.charCodeAt(0); + processText(str); + }; + + const processText = (str: string): void => { + for (let i = 0; i < str.length; i += 1) { + const code = str.charCodeAt(i); // Enter (CR or LF) if (code === 13 || code === 10) { + let nextIndex = i + 1; + if (code === 13 && str.charCodeAt(nextIndex) === 10) { + nextIndex += 1; + } + savePendingInput(str.slice(nextIndex)); finish(); return; } // Ctrl-C if (code === 3) { + savePendingInput(''); cleanup(); output.write('\n'); if (!resolved) { @@ -71,7 +84,7 @@ function readLine( } // Drop other control chars if (code < 32) continue; - buffer += ch; + buffer += str[i]; if (mask) output.write('*'); } }; @@ -89,9 +102,12 @@ function readLine( }; const cleanup = (): void => { - input.off('data', onData); - input.off('end', onEnd); - input.off('error', onError); + if (listening) { + input.off('data', onData); + input.off('end', onEnd); + input.off('error', onError); + listening = false; + } const pausable = input as { pause?: () => unknown }; if (typeof pausable.pause === 'function') pausable.pause(); }; @@ -105,12 +121,37 @@ function readLine( } }; + const savePendingInput = (text: string): void => { + if (text.length > 0) { + pendingPromptInput.set(input, text); + } else { + pendingPromptInput.delete(input); + } + }; + + const pending = pendingPromptInput.get(input); + if (pending !== undefined) { + pendingPromptInput.delete(input); + processText(pending); + if (resolved) return; + if (isInputEnded(input)) { + finish(); + return; + } + } + input.on('data', onData); input.on('end', onEnd); input.on('error', onError); + listening = true; const resumable = input as { resume?: () => unknown }; if (typeof resumable.resume === 'function') resumable.resume(); }); } +function isInputEnded(input: NodeJS.ReadableStream): boolean { + const state = input as { readableEnded?: boolean; destroyed?: boolean; closed?: boolean }; + return state.readableEnded === true || state.destroyed === true || state.closed === true; +} + export type { Writable };