[chore] commit id display on version
Publish snapshot / snapshot (push) Successful in 1m26s

This commit is contained in:
Benjamin Diedrichsen
2026-07-29 13:00:10 +02:00
parent ea08e76a2f
commit 1ba1c2a32a
17 changed files with 589 additions and 56 deletions
+132
View File
@@ -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);
};
}