This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* What happens when the user walks out of the TUI instead of finishing it.
|
||||
* @module nopy.exit
|
||||
*/
|
||||
|
||||
/** Parting words. Printed whenever a run ends because the user asked it to. */
|
||||
export const FAREWELL = 'Bye Bye Honeypie';
|
||||
|
||||
/** Conventional exit code for "terminated by SIGINT" — 128 + 2. */
|
||||
export const CANCELLED_EXIT_CODE = 130;
|
||||
|
||||
/** ETX: the byte a raw-mode terminal delivers for Ctrl-C. */
|
||||
const ETX = '\x03';
|
||||
|
||||
/** Undoes `ansi.cursor.hide()`, which every enquirer prompt writes on start. */
|
||||
const SHOW_CURSOR = '\x1B[?25h';
|
||||
|
||||
/**
|
||||
* Error names the two prompt libraries use for "the user called it off".
|
||||
*
|
||||
* `ExitPromptError` is what `@inquirer/core` rejects with on Ctrl-C;
|
||||
* `CancelPromptError` is the same thing reached from outside the prompt.
|
||||
*/
|
||||
const CANCEL_ERROR_NAMES = new Set(['ExitPromptError', 'CancelPromptError']);
|
||||
|
||||
/**
|
||||
* Whether a thrown value is the user cancelling rather than something failing.
|
||||
*
|
||||
* Three shapes, one per way out of a prompt:
|
||||
*
|
||||
* - `ERR_USE_AFTER_CLOSE` — enquirer's teardown exploding. Ctrl-C in raw mode
|
||||
* reaches *both* node's readline, which closes the interface because it has
|
||||
* no `SIGINT` listener, and enquirer's own keypress queue, which then cancels
|
||||
* the prompt and calls `rl.pause()` on the interface node has already closed.
|
||||
* Node >= 22 throws there rather than ignoring it. The throw happens inside
|
||||
* `Prompt.close()`, i.e. *before* `emit('cancel')`, so `prompt.run()` never
|
||||
* settles and the `try/catch` around it in `nopy.prompts` never runs — the
|
||||
* rejection surfaces with nothing awaiting it, which is why this has to be
|
||||
* caught at the process level.
|
||||
* - `ExitPromptError` — inquirer, which does reject cleanly and whose rejection
|
||||
* travels up the normal call chain.
|
||||
* - a bare `''` or an ETX byte — enquirer rejecting a cancelled prompt with the
|
||||
* keypress that cancelled it, on the runs where the teardown does not throw.
|
||||
*/
|
||||
export function isCancellation(error: unknown): boolean {
|
||||
if (error === '' || error === ETX) return true;
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
|
||||
const { name, code } = error as { name?: unknown; code?: unknown };
|
||||
return (
|
||||
code === 'ERR_USE_AFTER_CLOSE' || (typeof name === 'string' && CANCEL_ERROR_NAMES.has(name))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the terminal back the way it was found.
|
||||
*
|
||||
* A prompt owns the terminal while it runs: stdin is in raw mode and the cursor
|
||||
* is hidden. Exiting from under it leaves the shell with no cursor and no echo,
|
||||
* so this runs on every abnormal exit, cancelled or crashed. Best-effort by
|
||||
* design — a destroyed stdin throws on `setRawMode`, and a failure to tidy up
|
||||
* must not replace the message explaining why we are leaving.
|
||||
*/
|
||||
export function restoreTerminal(): void {
|
||||
try {
|
||||
if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false);
|
||||
if (process.stdout.isTTY) process.stdout.write(SHOW_CURSOR);
|
||||
} catch {
|
||||
// Nothing useful to do about a terminal that will not be restored.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Says goodbye and leaves.
|
||||
*
|
||||
* The farewell goes to **stderr**, for the same reason the update hint does:
|
||||
* `--json` and `--print-only` stay machine-readable no matter how the run ends.
|
||||
*
|
||||
* `process.exit` rather than letting the loop drain, because the prompt that
|
||||
* was cancelled is still holding stdin — after the teardown above threw, its
|
||||
* promise is pending forever and nothing else will end the process.
|
||||
*/
|
||||
export function exitWithFarewell(code: number = CANCELLED_EXIT_CODE): never {
|
||||
restoreTerminal();
|
||||
process.stderr.write(`\n${FAREWELL}\n`);
|
||||
return process.exit(code) as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a genuine crash, having first handed the terminal back.
|
||||
*
|
||||
* Deliberately as loud as node's own default — the stack, not a summary. The
|
||||
* only thing being taken over is *when* it prints, so that {@link
|
||||
* restoreTerminal} gets to run first.
|
||||
*/
|
||||
function reportFatal(error: unknown): never {
|
||||
restoreTerminal();
|
||||
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
||||
return process.exit(1) as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the process-level handlers that turn a Ctrl-C into {@link FAREWELL}.
|
||||
*
|
||||
* Two entry points, because Ctrl-C arrives differently depending on who owns
|
||||
* the terminal. During a prompt, stdin is in raw mode: the process gets no
|
||||
* `SIGINT` at all, the keypress goes to the prompt library, and the failure
|
||||
* comes back as an unhandled rejection. Everywhere else — cube loading, a
|
||||
* pyinfra run — the signal arrives normally.
|
||||
*
|
||||
* Returns a disposer, which the CLI ignores and the tests do not.
|
||||
*/
|
||||
export function installGracefulExit(): () => void {
|
||||
const onSignal = () => exitWithFarewell();
|
||||
const onFatal = (reason: unknown) => {
|
||||
if (isCancellation(reason)) {
|
||||
exitWithFarewell();
|
||||
return;
|
||||
}
|
||||
reportFatal(reason);
|
||||
};
|
||||
|
||||
process.on('SIGINT', onSignal);
|
||||
process.on('uncaughtException', onFatal);
|
||||
process.on('unhandledRejection', onFatal);
|
||||
|
||||
return () => {
|
||||
process.off('SIGINT', onSignal);
|
||||
process.off('uncaughtException', onFatal);
|
||||
process.off('unhandledRejection', onFatal);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user