From 62ff6095f045c013e2aca86fb1060ac99ebaecbb Mon Sep 17 00:00:00 2001 From: Jacob Paris Date: Tue, 17 Dec 2024 13:40:08 +0700 Subject: [PATCH 01/15] feature: add abort controller --- packages/core/src/prompts/prompt.ts | 12 +++++++++++- packages/core/test/prompts/prompt.test.ts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index f2c85771..9e4e63cc 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -17,11 +17,13 @@ export interface PromptOptions { input?: Readable; output?: Writable; debug?: boolean; + signal?: AbortSignal; } export default class Prompt { protected input: Readable; protected output: Writable; + private abortController?: AbortSignal; private rl!: ReadLine; private opts: Omit, 'render' | 'input' | 'output'>; @@ -36,7 +38,7 @@ export default class Prompt { public value: any; constructor(options: PromptOptions, trackValue = true) { - const { input = stdin, output = stdout, render, ...opts } = options; + const { input = stdin, output = stdout, render, signal, ...opts } = options; this.opts = opts; this.onKeypress = this.onKeypress.bind(this); @@ -44,6 +46,7 @@ export default class Prompt { this.render = this.render.bind(this); this._render = render.bind(this); this._track = trackValue; + this.abortController = signal; this.input = input; this.output = output; @@ -111,6 +114,13 @@ export default class Prompt { public prompt() { return new Promise((resolve, reject) => { + if (this.abortController) { + this.abortController.addEventListener('abort', () => { + this.state = 'cancel'; + this.close(); + }, { once: true }); + } + const sink = new WriteStream(0); sink._write = (chunk, encoding, done) => { if (this._track) { diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index 0f976c34..dcb78b28 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -229,4 +229,23 @@ describe('Prompt', () => { expect(eventSpy).toBeCalledWith(key); } }); + + test('aborts on abort signal', () => { + const abortController = new AbortController(); + + const instance = new Prompt({ + input, + output, + render: () => 'foo', + signal: abortController.signal, + }); + + instance.prompt(); + + expect(instance.state).to.equal('active'); + + abortController.abort(); + + expect(instance.state).to.equal('cancel'); + }); }); From 6db1c83396e43958a24744925190b45a3209fc46 Mon Sep 17 00:00:00 2001 From: Jacob Paris Date: Tue, 17 Dec 2024 14:15:06 +0700 Subject: [PATCH 02/15] dev: add pkg-pr-new --- .github/workflows/preview.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/preview.yml diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..3db3255a --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,40 @@ +name: Preview +on: + pull_request_review: + types: [submitted] + +jobs: + check: + # First, trigger a permissions check on the user approving the pull request. + if: github.event.review.state == 'approved' + runs-on: ubuntu-latest + outputs: + has-permissions: ${{ steps.checkPermissions.outputs.require-result }} + steps: + - name: Check permissions + id: checkPermissions + uses: actions-cool/check-user-permission@v2 + with: + # In this example, the approver must have the write access + # to the repository to trigger the package preview. + require: "write" + + publish: + needs: check + # Publish the preview package only if the permissions check passed. + if: needs.check.outputs.has-permissions == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - run: pnpx pkg-pr-new publish From 81075c68ca2d46b1942ee4c65ff26a595471b794 Mon Sep 17 00:00:00 2001 From: jacobparis Date: Tue, 17 Dec 2024 07:23:05 +0000 Subject: [PATCH 03/15] [ci] format --- packages/core/src/prompts/prompt.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 9e4e63cc..5b75c314 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -115,10 +115,14 @@ export default class Prompt { public prompt() { return new Promise((resolve, reject) => { if (this.abortController) { - this.abortController.addEventListener('abort', () => { - this.state = 'cancel'; - this.close(); - }, { once: true }); + this.abortController.addEventListener( + 'abort', + () => { + this.state = 'cancel'; + this.close(); + }, + { once: true } + ); } const sink = new WriteStream(0); From 2635642c3eca849aa0430efef95c4635c02a42a9 Mon Sep 17 00:00:00 2001 From: Jacob Paris Date: Tue, 17 Dec 2024 14:23:35 +0700 Subject: [PATCH 04/15] fix: add comment to trigger deploy --- packages/core/src/prompts/prompt.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 9e4e63cc..28ecfe66 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -9,6 +9,7 @@ import { ALIASES, CANCEL_SYMBOL, KEYS, diffLines, hasAliasKey, setRawMode } from import type { ClackEvents, ClackState, InferSetType } from '../types'; +// trigger build export interface PromptOptions { render(this: Omit): string | undefined; placeholder?: string; From 51c991d07569ec737bd60e6c0671c583497de851 Mon Sep 17 00:00:00 2001 From: Jacob Paris Date: Tue, 17 Dec 2024 14:26:40 +0700 Subject: [PATCH 05/15] fix: on pr --- .github/workflows/preview.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 3db3255a..44b77641 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,5 +1,6 @@ name: Preview on: + pull_request: pull_request_review: types: [submitted] From 26a4e457a8925deaef250d3a3d40fad4e8520b93 Mon Sep 17 00:00:00 2001 From: Jacob Paris Date: Tue, 17 Dec 2024 14:27:58 +0700 Subject: [PATCH 06/15] fix: remove permissions checks I am the boss --- .github/workflows/preview.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 44b77641..a6ac817a 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -5,25 +5,8 @@ on: types: [submitted] jobs: - check: - # First, trigger a permissions check on the user approving the pull request. - if: github.event.review.state == 'approved' - runs-on: ubuntu-latest - outputs: - has-permissions: ${{ steps.checkPermissions.outputs.require-result }} - steps: - - name: Check permissions - id: checkPermissions - uses: actions-cool/check-user-permission@v2 - with: - # In this example, the approver must have the write access - # to the repository to trigger the package preview. - require: "write" publish: - needs: check - # Publish the preview package only if the permissions check passed. - if: needs.check.outputs.has-permissions == 'true' runs-on: ubuntu-latest steps: - name: Checkout code From 05cbdc571a28fda4f7fdf4d94678fa67841c96f5 Mon Sep 17 00:00:00 2001 From: Jacob Paris Date: Tue, 17 Dec 2024 14:53:21 +0700 Subject: [PATCH 07/15] fix: use real package --- .github/workflows/preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index a6ac817a..4623ae5d 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -21,4 +21,4 @@ jobs: - name: Install dependencies run: pnpm install - - run: pnpx pkg-pr-new publish + - run: pnpx pkg-pr-new publish --package ./packages/core From 97de1cd069f09a0ba32a6dc44a6b71c68c3c11ab Mon Sep 17 00:00:00 2001 From: Jacob Paris Date: Tue, 17 Dec 2024 14:57:43 +0700 Subject: [PATCH 08/15] read the docs, jacob --- .github/workflows/preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 4623ae5d..9bab9d02 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -21,4 +21,4 @@ jobs: - name: Install dependencies run: pnpm install - - run: pnpx pkg-pr-new publish --package ./packages/core + - run: pnpx pkg-pr-new publish './packages/*' From d4a4973c2f1579d183155aa66f31258063f57b8f Mon Sep 17 00:00:00 2001 From: Jacob Paris Date: Tue, 17 Dec 2024 15:46:35 +0700 Subject: [PATCH 09/15] fix: early exit if pre-aborted --- packages/core/src/prompts/prompt.ts | 14 ++++++++++++++ packages/core/test/prompts/prompt.test.ts | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 9e4e63cc..55a7ec56 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -115,6 +115,20 @@ export default class Prompt { public prompt() { return new Promise((resolve, reject) => { if (this.abortController) { + if (this.abortController.aborted) { + this.state = 'cancel'; + + this.input.unpipe(); + this.input.removeListener('keypress', this.onKeypress); + this.output.write('\n'); + setRawMode(this.input, false); + // this.rl.close(); // The readline interface is not set up yet + this.emit(`${this.state}`, this.value); + this.unsubscribe(); + + return resolve(CANCEL_SYMBOL); + } + this.abortController.addEventListener('abort', () => { this.state = 'cancel'; this.close(); diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index dcb78b28..44318c88 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -248,4 +248,14 @@ describe('Prompt', () => { expect(instance.state).to.equal('cancel'); }); + + test('returns immediately if signal is already aborted', () => { + const abortController = new AbortController(); + abortController.abort(); + + const instance = new Prompt({ input, output, render: () => 'foo', signal: abortController.signal }); + instance.prompt(); + + expect(instance.state).to.equal('cancel'); + }); }); From e60e69a705ad966e96d999996fe8488a0a3fd6c2 Mon Sep 17 00:00:00 2001 From: Jacob Paris Date: Wed, 18 Dec 2024 02:24:41 -0500 Subject: [PATCH 10/15] Delete .github/workflows/preview.yml --- .github/workflows/preview.yml | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 .github/workflows/preview.yml diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml deleted file mode 100644 index 9bab9d02..00000000 --- a/.github/workflows/preview.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Preview -on: - pull_request: - pull_request_review: - types: [submitted] - -jobs: - - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - run: corepack enable - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "pnpm" - - - name: Install dependencies - run: pnpm install - - - run: pnpx pkg-pr-new publish './packages/*' From 0423b9a30387cf51cb6c7f4a5107e359cc3c4865 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 19 Dec 2024 12:31:58 -0600 Subject: [PATCH 11/15] refactor: rename `abortController` to `_abortSignal` --- packages/core/src/prompts/prompt.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 1122f5be..b1d03c19 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -24,7 +24,7 @@ export interface PromptOptions { export default class Prompt { protected input: Readable; protected output: Writable; - private abortController?: AbortSignal; + private _abortSignal?: AbortSignal; private rl!: ReadLine; private opts: Omit, 'render' | 'input' | 'output'>; @@ -47,7 +47,7 @@ export default class Prompt { this.render = this.render.bind(this); this._render = render.bind(this); this._track = trackValue; - this.abortController = signal; + this._abortSignal = signal; this.input = input; this.output = output; @@ -115,8 +115,8 @@ export default class Prompt { public prompt() { return new Promise((resolve, reject) => { - if (this.abortController) { - if (this.abortController.aborted) { + if (this._abortSignal) { + if (this._abortSignal.aborted) { this.state = 'cancel'; this.input.unpipe(); @@ -130,7 +130,7 @@ export default class Prompt { return resolve(CANCEL_SYMBOL); } - this.abortController.addEventListener('abort', () => { + this._abortSignal.addEventListener('abort', () => { this.state = 'cancel'; this.close(); }, { once: true }); From 155799643fe1b5bd8cb404af62be128eb7ab3bc7 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 19 Dec 2024 12:36:01 -0600 Subject: [PATCH 12/15] refactor: handle readline more safely --- packages/core/src/prompts/prompt.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index b1d03c19..43d431eb 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -26,7 +26,7 @@ export default class Prompt { protected output: Writable; private _abortSignal?: AbortSignal; - private rl!: ReadLine; + private rl: ReadLine | undefined; private opts: Omit, 'render' | 'input' | 'output'>; private _render: (context: Omit) => string | undefined; private _track = false; @@ -119,14 +119,7 @@ export default class Prompt { if (this._abortSignal.aborted) { this.state = 'cancel'; - this.input.unpipe(); - this.input.removeListener('keypress', this.onKeypress); - this.output.write('\n'); - setRawMode(this.input, false); - // this.rl.close(); // The readline interface is not set up yet - this.emit(`${this.state}`, this.value); - this.unsubscribe(); - + this.close(); return resolve(CANCEL_SYMBOL); } @@ -139,8 +132,8 @@ export default class Prompt { const sink = new WriteStream(0); sink._write = (chunk, encoding, done) => { if (this._track) { - this.value = this.rl.line.replace(/\t/g, ''); - this._cursor = this.rl.cursor; + this.value = this.rl?.line.replace(/\t/g, ''); + this._cursor = this.rl?.cursor ?? 0; this.emit('value', this.value); } done(); @@ -196,7 +189,7 @@ export default class Prompt { } if (char === '\t' && this.opts.placeholder) { if (!this.value) { - this.rl.write(this.opts.placeholder); + this.rl?.write(this.opts.placeholder); this.emit('value', this.opts.placeholder); } } @@ -210,7 +203,7 @@ export default class Prompt { if (problem) { this.error = problem; this.state = 'error'; - this.rl.write(this.value); + this.rl?.write(this.value); } } if (this.state !== 'error') { @@ -235,7 +228,8 @@ export default class Prompt { this.input.removeListener('keypress', this.onKeypress); this.output.write('\n'); setRawMode(this.input, false); - this.rl.close(); + this.rl?.close(); + this.rl = undefined; this.emit(`${this.state}`, this.value); this.unsubscribe(); } From 16a65d4a3a14031539cbf2b1c4c1d908dd73bf66 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 19 Dec 2024 12:43:18 -0600 Subject: [PATCH 13/15] chore: add changeset --- .changeset/lucky-maps-beam.md | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/lucky-maps-beam.md diff --git a/.changeset/lucky-maps-beam.md b/.changeset/lucky-maps-beam.md new file mode 100644 index 00000000..3186040b --- /dev/null +++ b/.changeset/lucky-maps-beam.md @@ -0,0 +1,36 @@ +--- +"@clack/core": minor +"@clack/prompts": minor +--- + +Adds a new `signal` option to support programmatic prompt cancellation with an [abort controller](https://kettanaito.com/blog/dont-sleep-on-abort-controller). + +One example use case is automatically cancelling a prompt after a timeout. + +```ts +const shouldContinue = await confirm({ + message: 'This message will self destruct in 5 seconds', + signal: AbortSignal.timeout(3000), +}); +``` + +Another use case is a long running task which can be cancelled if the user submits a response to the prompt. + +```ts +const abortController = new AbortController() + +const projectType = await Promise.race([ + detectSystemSettings({ + signal: abortController.signal + }), + select({ + message: 'Pick a project type.', + options: [ + { value: 'ts', label: 'TypeScript' }, + { value: 'js', label: 'JavaScript' }, + { value: 'coffee', label: 'CoffeeScript', hint: 'oh no'}, + ], + signal: abortController.signal, + }); +]) +``` From 8caca55245eff2eb36ddc4c79fffefb2b56fe3bf Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 19 Dec 2024 12:48:49 -0600 Subject: [PATCH 14/15] chore: update README --- .changeset/lucky-maps-beam.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/lucky-maps-beam.md b/.changeset/lucky-maps-beam.md index 3186040b..2f50404f 100644 --- a/.changeset/lucky-maps-beam.md +++ b/.changeset/lucky-maps-beam.md @@ -10,17 +10,17 @@ One example use case is automatically cancelling a prompt after a timeout. ```ts const shouldContinue = await confirm({ message: 'This message will self destruct in 5 seconds', - signal: AbortSignal.timeout(3000), + signal: AbortSignal.timeout(5000), }); ``` -Another use case is a long running task which can be cancelled if the user submits a response to the prompt. +Another use case is racing a long running task with a manual prompt. ```ts const abortController = new AbortController() const projectType = await Promise.race([ - detectSystemSettings({ + detectProjectType({ signal: abortController.signal }), select({ @@ -31,6 +31,8 @@ const projectType = await Promise.race([ { value: 'coffee', label: 'CoffeeScript', hint: 'oh no'}, ], signal: abortController.signal, - }); + }) ]) + +abortController.abort() ``` From 8214d75ba844b86cb5a4d1ef379e47545c66209a Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 19 Dec 2024 12:49:27 -0600 Subject: [PATCH 15/15] Update packages/core/src/prompts/prompt.ts --- packages/core/src/prompts/prompt.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 43d431eb..01da83ba 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -9,7 +9,6 @@ import { ALIASES, CANCEL_SYMBOL, KEYS, diffLines, hasAliasKey, setRawMode } from import type { ClackEvents, ClackState, InferSetType } from '../types'; -// trigger build export interface PromptOptions { render(this: Omit): string | undefined; placeholder?: string;