133 lines
5.0 KiB
TypeScript
133 lines
5.0 KiB
TypeScript
/**
|
|
* 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 HoneyPy';
|
|
|
|
/** 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);
|
|
};
|
|
}
|