diff --git a/.changeset/clever-rules-invent.md b/.changeset/clever-rules-invent.md new file mode 100644 index 00000000..78f95e39 --- /dev/null +++ b/.changeset/clever-rules-invent.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Fixed spinner onCancel not being called on Ctrl+C and prevents process.exit call diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 84ddd611..8c322ef8 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -29,6 +29,7 @@ interface BlockOptions { output?: Writable; overwrite?: boolean; hideCursor?: boolean; + onSoftCancel?: () => void; } export function block({ @@ -36,6 +37,7 @@ export function block({ output = stdout, overwrite = true, hideCursor = true, + onSoftCancel, }: BlockOptions = {}) { const rl = readline.createInterface({ input, @@ -52,8 +54,12 @@ export function block({ const clear = (data: Buffer, { name, sequence }: Key) => { const str = String(data); if (isActionKey([str, name, sequence], 'cancel')) { - if (hideCursor) output.write(cursor.show); - process.exit(0); + if (typeof onSoftCancel === 'function') { + onSoftCancel(); + } else { + if (hideCursor) output.write(cursor.show); + process.exit(0); + } return; } if (!overwrite) return; @@ -78,8 +84,10 @@ export function block({ input.setRawMode(false); } - // @ts-expect-error fix for https://github.com/nodejs/node/issues/31762#issuecomment-1441223907 - rl.terminal = false; + if (typeof onSoftCancel !== 'function') { + // @ts-expect-error fix for https://github.com/nodejs/node/issues/31762#issuecomment-1441223907 + rl.terminal = false; + } rl.close(); }; } diff --git a/packages/prompts/README.md b/packages/prompts/README.md index 7b4faa7e..57c4a318 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -271,6 +271,27 @@ s.start('Installing via npm'); s.stop('Installed via npm'); ``` +Note: The `Ctrl+C` input will hard-stop the process with a `process.exit(0)` call. +If you wish to avoid this behavior, pass an `onCancel` callback to the spinner initialization function. + +```js +import { spinner, log } from '@clack/prompts'; + +const s = spinner({ + onCancel: () => { + // Do something...or nothing + log.warn('Coolness reached 3.14%'); + } +}); + +s.start('Downloading coolness...'); +// Do stuff...then hit Ctrl+C to cancel +s.stop('Maximum coolness reached! :)'); // Note: This does nothing if the spinner was cancelled + +// Carry on... (teardown connections, etc.) + +``` + ### Progress The progress component extends the spinner component to add a progress bar to visualize the progression of an action. diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 618ae427..668adab7 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -56,6 +56,7 @@ export const spinner = ({ let _origin: number = performance.now(); const columns = getColumns(output); const styleFn = opts?.styleFrame ?? defaultStyleFn; + const isSoftCancel = typeof onCancel === 'function'; const handleExit = (code: number) => { const msg = @@ -131,7 +132,7 @@ export const spinner = ({ const start = (msg = ''): void => { isSpinnerActive = true; - unblock = block({ output }); + unblock = block({ output, onSoftCancel: isSoftCancel ? () => handleExit(1) : undefined }); _message = removeTrailingDots(msg); _origin = performance.now(); if (hasGuide) { diff --git a/packages/prompts/test/__snapshots__/spinner.test.ts.snap b/packages/prompts/test/__snapshots__/spinner.test.ts.snap index 31412ea1..eff2dcc9 100644 --- a/packages/prompts/test/__snapshots__/spinner.test.ts.snap +++ b/packages/prompts/test/__snapshots__/spinner.test.ts.snap @@ -11,6 +11,17 @@ exports[`spinner (isCI = false) > can be aborted by a signal 1`] = ` ] `; +exports[`spinner (isCI = false) > can be cancelled by Ctrl+C without exiting process 1`] = ` +[ + "", + "│ +", + "■ Canceled +", + "", +] +`; + exports[`spinner (isCI = false) > clear > stops and clears the spinner from the output 1`] = ` [ "", @@ -324,6 +335,15 @@ exports[`spinner (isCI = false) > message > sets message for next frame 1`] = ` ] `; +exports[`spinner (isCI = false) > process exit handling > hard-exits the process on Ctrl+C when no onCancel callback is provided 1`] = ` +[ + "", + "│ +", + "", +] +`; + exports[`spinner (isCI = false) > process exit handling > prioritizes cancel option over global setting 1`] = ` [ "", @@ -606,6 +626,17 @@ exports[`spinner (isCI = true) > can be aborted by a signal 1`] = ` ] `; +exports[`spinner (isCI = true) > can be cancelled by Ctrl+C without exiting process 1`] = ` +[ + "", + "│ +", + "■ Canceled +", + "", +] +`; + exports[`spinner (isCI = true) > clear > stops and clears the spinner from the output 1`] = ` [ "", @@ -719,6 +750,15 @@ exports[`spinner (isCI = true) > message > sets message for next frame 1`] = ` ] `; +exports[`spinner (isCI = true) > process exit handling > hard-exits the process on Ctrl+C when no onCancel callback is provided 1`] = ` +[ + "", + "│ +", + "", +] +`; + exports[`spinner (isCI = true) > process exit handling > prioritizes cancel option over global setting 1`] = ` [ "", diff --git a/packages/prompts/test/spinner.test.ts b/packages/prompts/test/spinner.test.ts index 18896121..e5e2ff04 100644 --- a/packages/prompts/test/spinner.test.ts +++ b/packages/prompts/test/spinner.test.ts @@ -411,6 +411,36 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { prompts.settings.messages.cancel = originalCancelMessage; } }); + + test('hard-exits the process on Ctrl+C when no onCancel callback is provided', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + return code ?? 0; + }) as typeof process.exit); + const result = prompts.spinner({ output }); + + result.start('Testing'); + + // Simulate Ctrl+C keypress + const ctrlCEvent = Buffer.from([0x03]); // ASCII for Ctrl+C + process.stdin.emit('keypress', ctrlCEvent, { name: 'c', ctrl: true }); + + expect(output.buffer).toMatchSnapshot(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + }); + + test('can be cancelled by Ctrl+C without exiting process', () => { + const onCancel = vi.fn(); + const result = prompts.spinner({ output, onCancel }); + + result.start('Testing'); + + // Simulate Ctrl+C keypress + const ctrlCEvent = Buffer.from([0x03]); // ASCII for Ctrl+C + process.stdin.emit('keypress', ctrlCEvent, { name: 'c', ctrl: true }); + + expect(onCancel).toHaveBeenCalled(); + expect(output.buffer).toMatchSnapshot(); }); test('can be aborted by a signal', async () => {