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
35 changes: 35 additions & 0 deletions src/lib/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}`]);
Expand Down
53 changes: 47 additions & 6 deletions src/lib/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ interface RawModeCapable {
resume?: () => unknown;
}

const pendingPromptInput = new WeakMap<NodeJS.ReadableStream, string>();

export async function promptText(question: string, streams: PromptStreams = {}): Promise<string> {
const input = streams.input ?? process.stdin;
const output = streams.output ?? process.stdout;
Expand Down Expand Up @@ -41,18 +43,29 @@ function readLine(
return new Promise<string>((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) {
Expand All @@ -71,7 +84,7 @@ function readLine(
}
// Drop other control chars
if (code < 32) continue;
buffer += ch;
buffer += str[i];
if (mask) output.write('*');
}
};
Expand All @@ -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();
};
Expand All @@ -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 };
Loading