Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/quiet-pipes-end.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export default class Prompt<TValue> {

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);
Expand Down Expand Up @@ -194,9 +195,27 @@ export default class Prompt<TValue> {
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';
}
Expand Down Expand Up @@ -293,6 +312,7 @@ export default class Prompt<TValue> {
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();
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/prompts/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading