From cddd57576b66330fec8e92486ffb9b75f23bb495 Mon Sep 17 00:00:00 2001 From: Fadhlan Date: Mon, 19 Oct 2026 15:50:36 +0800 Subject: [PATCH] fix(core): cancel prompts when the input stream ends --- .changeset/quiet-pipes-end.md | 5 +++ packages/core/src/prompts/prompt.ts | 20 ++++++++++ packages/core/test/prompts/prompt.test.ts | 48 +++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 .changeset/quiet-pipes-end.md diff --git a/.changeset/quiet-pipes-end.md b/.changeset/quiet-pipes-end.md new file mode 100644 index 00000000..15ae9df4 --- /dev/null +++ b/.changeset/quiet-pipes-end.md @@ -0,0 +1,5 @@ +--- +"@clack/core": patch +--- + +Cancel a prompt when its input stream ends (e.g. stdin is `/dev/null` or piped input runs out) instead of leaving the returned promise pending forever. diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index d1c61961..0391d24f 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -70,6 +70,7 @@ export default class Prompt { this.opts = opts; this.onKeypress = this.onKeypress.bind(this); + this.onInputEnd = this.onInputEnd.bind(this); this.close = this.close.bind(this); this.render = this.render.bind(this); this._render = render.bind(this); @@ -194,9 +195,27 @@ export default class Prompt { setRawMode(this.input, false); resolve(CANCEL_SYMBOL); }); + + // No answer can arrive once the input has ended (e.g. stdin is + // `/dev/null` or a pipe that ran out), so cancel instead of hanging. + if (this.input.readableEnded) { + this.onInputEnd(); + } else { + this.input.once('end', this.onInputEnd); + } }); } + private onInputEnd() { + if (this.state === 'submit' || this.state === 'cancel') { + return; + } + this.state = 'cancel'; + this.emit('finalize'); + this.render(); + this.close(); + } + protected _isActionKey(char: string | undefined, _key: Key): boolean { return char === '\t'; } @@ -293,6 +312,7 @@ export default class Prompt { protected close() { this.input.unpipe(); this.input.removeListener('keypress', this.onKeypress); + this.input.removeListener('end', this.onInputEnd); this.output.write('\n'); setRawMode(this.input, false); this.rl?.close(); diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index 50c1ccda..6e1b75aa 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -236,6 +236,54 @@ describe('Prompt', () => { expect(instance.state).to.equal('cancel'); }); + test('cancels when the input ends', async () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.close(); + input.resume(); + + const result = await resultPromise; + expect(isCancel(result)).to.equal(true); + expect(instance.state).to.equal('cancel'); + expect(output.buffer.at(-1)).to.equal(cursor.show); + expect(input.listenerCount('end')).to.equal(0); + }); + + test('cancels immediately if the input has already ended', async () => { + input.close(); + input.resume(); + await new Promise((resolve) => input.once('end', resolve)); + + const instance = new Prompt({ + input, + output, + render: () => 'foo', + }); + const result = await instance.prompt(); + expect(isCancel(result)).to.equal(true); + expect(instance.state).to.equal('cancel'); + }); + + test('does not cancel a submitted prompt when the input ends afterwards', async () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', '', { name: 'return' }); + input.close(); + input.resume(); + + const result = await resultPromise; + expect(isCancel(result)).to.equal(false); + expect(instance.state).to.equal('submit'); + }); + describe('function validation', () => { test('accepts invalid initial value', () => { const instance = new Prompt({