# closeout/cli-cursor > Every export of closeout/cli-cursor, with its signature and doc comment: cliCursor. Source: https://closeout.interlace.tools/docs/api/cli-cursor ```ts import cliCursor from 'closeout/cli-cursor'; ``` ## Constants ### default The default export, declared as `cliCursor`. ```ts const cliCursor: { show: typeof show; hide: typeof hide; toggle: typeof toggle; }; ``` --- # closeout/cursor > Every export of closeout/cursor, with its signature and doc comment: showCursor, hideCursor, alternateScreen, bracketedPaste, rawMode, HIDE_CURSOR and 5 more, plus 3 types. Source: https://closeout.interlace.tools/docs/api/cursor ```ts import { showCursor, hideCursor, alternateScreen, … } from 'closeout/cursor'; ``` ## Functions ### alternateScreen Enter the alternate screen, and register leaving it. ```ts function alternateScreen(stream: OutputStream, onExit: Registrar): () => void; ``` | Parameter | Type | | :-- | :-- | | `stream` | `OutputStream` | | `onExit` | `Registrar` | **Returns** `() => void` ### bracketedPaste Turn bracketed paste on, and register turning it off, with {@link alternateScreen}'s contract: once only, early calls unregister, and a non-TTY gets nothing in either direction. ```ts function bracketedPaste(stream: OutputStream, onExit: Registrar): () => void; ``` | Parameter | Type | | :-- | :-- | | `stream` | `OutputStream` | | `onExit` | `Registrar` | **Returns** `() => void` ### hideCursor Hide the cursor, and register its restore. ```ts function hideCursor(stream: OutputStream, onExit: Registrar): () => void; ``` | Parameter | Type | | :-- | :-- | | `stream` | `OutputStream` | | `onExit` | `Registrar` | **Returns** `() => void` ### rawMode Turn raw mode on, and register turning it off. ```ts function rawMode(input: InputStream, onExit: Registrar): () => void; ``` | Parameter | Type | | :-- | :-- | | `input` | `InputStream` | | `onExit` | `Registrar` | **Returns** `() => void` ### showCursor Show the cursor. Safe to call when it was never hidden, and safe to call twice — the sequence is idempotent, which is what makes it usable from an exit path that may run after a caller has already cleaned up. ```ts function showCursor(stream: OutputStream): void; ``` | Parameter | Type | | :-- | :-- | | `stream` | `OutputStream` | **Returns** `void` ## Constants ### DISABLE_BRACKETED_PASTE ```ts const DISABLE_BRACKETED_PASTE = "\u001B[?2004l"; ``` ### ENABLE_BRACKETED_PASTE Bracketed paste, DEC private mode 2004: while it is on, the terminal wraps pasted text in `ESC[200~ … ESC[201~`, so a line editor can take a multi-line paste as text instead of as keys — Enter included — and a terminal left with it on wraps every later paste in the shell. ```ts const ENABLE_BRACKETED_PASTE = "\u001B[?2004h"; ``` ### ENTER_ALTERNATE_SCREEN The alternate screen, DEC private mode 1049: entering saves the cursor and switches to a blank buffer; leaving switches back and restores it, so the user's scrollback is as it was. ```ts const ENTER_ALTERNATE_SCREEN = "\u001B[?1049h"; ``` ### HIDE_CURSOR The sequences this layer exists to undo, published because they are its vocabulary: anything that hides a cursor owes a show on every exit path, and a caller writing them by hand should be writing the same bytes we restore. ```ts const HIDE_CURSOR = "\u001B[?25l"; ``` ### LEAVE_ALTERNATE_SCREEN ```ts const LEAVE_ALTERNATE_SCREEN = "\u001B[?1049l"; ``` ### SHOW_CURSOR ```ts const SHOW_CURSOR = "\u001B[?25h"; ``` ## Interfaces ### InputStream The half of `tty.ReadStream` raw mode needs. `setRawMode` is optional because a piped `process.stdin` has none, and a caller should be able to pass `process.stdin` either way. ```ts interface InputStream { isTTY?: boolean; /** Node's own record of the mode, read to learn whether somebody else turned it on. */ isRaw?: boolean; setRawMode?(mode: boolean): unknown; } ``` ### OutputStream The half of `NodeJS.WriteStream` this needs, so a test can pass a recorder. ```ts interface OutputStream { write(chunk: string): unknown; isTTY?: boolean; } ``` ## Types ### Registrar Where a pairing registers its undo — closeout's registry, or a caller's own. ```ts type Registrar = (handler: () => void) => () => void; ``` --- # closeout/exit-hook > Every export of closeout/exit-hook, with its signature and doc comment: asyncExitHook, gracefulExit, exitHook, plus 3 types. Source: https://closeout.interlace.tools/docs/api/exit-hook ```ts import exitHook from 'closeout/exit-hook'; import { asyncExitHook, gracefulExit } from 'closeout/exit-hook'; ``` ## Functions ### default The default export, declared as `exitHook`. Register a synchronous hook. Returns the function that unregisters it. ```ts function exitHook(onExit: ExitHookCallback): () => void; ``` | Parameter | Type | | :-- | :-- | | `onExit` | `ExitHookCallback` | **Returns** `() => void` ### asyncExitHook Register an asynchronous hook, bounded by `wait`. ```ts function asyncExitHook(onExit: ExitHookCallback, options?: Partial): () => void; ``` | Parameter | Type | | :-- | :-- | | `onExit` | `ExitHookCallback` | | `options` (optional) | `Partial` | **Returns** `() => void` ### gracefulExit Exit the way `process.exit()` cannot: asynchronous hooks get to run, and stdio is drained before the process goes. ```ts function gracefulExit(signal?: number): void; ``` | Parameter | Type | | :-- | :-- | | `signal` (optional) | `number` | **Returns** `void` ## Interfaces ### AsyncExitHookOptions ```ts interface AsyncExitHookOptions { /** * How long this hook may take, in milliseconds. Required and positive — `exit-hook` throws * on a missing or non-numeric value and its `type enforcing` case asserts both. */ wait: number; } ``` ## Types ### ExitHookCallback A hook takes the code the process is about to leave with. ```ts type ExitHookCallback = (exitCode: number | string) => unknown; ``` ### Options ```ts type Options = AsyncExitHookOptions; ``` --- # closeout > Every export of closeout, with its signature and doc comment: alternateScreen, assertDeadline, createRegistry, DEADLINE_ERROR_CODE, DeadlineError, DEFAULT_DEADLINE and 17 more, plus 16 types. Source: https://closeout.interlace.tools/docs/api closeout — close everything out. ```ts import { alternateScreen, assertDeadline, createRegistry, … } from 'closeout'; ``` ## Functions ### alternateScreen Enter the alternate screen and register leaving it in `restore`; the returned function leaves it. Registers on the process-wide instance unless `closeout` names another. ```ts function alternateScreen(stream: OutputStream, closeout?: Closeout): () => void; ``` | Parameter | Type | | :-- | :-- | | `stream` | `OutputStream` | | `closeout` (optional) | `Closeout` | **Returns** `() => void` ### assertDeadline Check a deadline at the moment it is registered, not at the moment it would have fired. ```ts function assertDeadline(ms: number): number; ``` | Parameter | Type | | :-- | :-- | | `ms` | `number` | **Returns** `number` ### createRegistry ```ts function createRegistry(options?: RegistryOptions): Registry; ``` | Parameter | Type | | :-- | :-- | | `options` (optional) | `RegistryOptions` | **Returns** `Registry` ### hideCursor Hide the cursor and register its restore; the returned function shows it again. ```ts function hideCursor(stream: OutputStream): () => void; ``` | Parameter | Type | | :-- | :-- | | `stream` | `OutputStream` | **Returns** `() => void` ### install Wire a registry to a process, and return the handle a program registers through. ```ts function install(options?: InstallOptions): Closeout; ``` | Parameter | Type | | :-- | :-- | | `options` (optional) | `InstallOptions` | **Returns** `Closeout` ### onExit Register a handler that runs exactly once, on every path out of the program. ```ts function onExit(handler: ExitHandler, spec?: HandlerSpec): () => void; ``` | Parameter | Type | | :-- | :-- | | `handler` | `ExitHandler` | | `spec` (optional) | `HandlerSpec` | **Returns** `() => void` ### rawMode Turn raw mode on and register turning it off in `restore`; the returned function turns it off. An input that was already raw belongs to somebody else and is left alone, now and at exit. Registers on the process-wide instance unless `closeout` names another. ```ts function rawMode(input: InputStream, closeout?: Closeout): () => void; ``` | Parameter | Type | | :-- | :-- | | `input` | `InputStream` | | `closeout` (optional) | `Closeout` | **Returns** `() => void` ### reportToEvent The agent rendering. Same values, one key added, still a projection of the one record. ```ts function reportToEvent(report: ShutdownReport): ExitEvent; ``` | Parameter | Type | | :-- | :-- | | `report` | `ShutdownReport` | **Returns** `ExitEvent` ### reportToJson The `--json` rendering: one line, machine-first, `error` flattened to text. ```ts function reportToJson(report: ShutdownReport): string; ``` | Parameter | Type | | :-- | :-- | | `report` | `ShutdownReport` | **Returns** `string` ### showCursor Show the cursor. Idempotent, and a no-op on a non-TTY. ```ts function showCursor(stream: OutputStream): void; ``` | Parameter | Type | | :-- | :-- | | `stream` | `OutputStream` | **Returns** `void` ### timeoutMessage The line the deadline prints when it fires. ```ts function timeoutMessage(report: ShutdownReport, deadline: number): string; ``` | Parameter | Type | | :-- | :-- | | `report` | `ShutdownReport` | | `deadline` | `number` | **Returns** `string` ## Classes ### DeadlineError ```ts class DeadlineError extends TypeError { readonly code = "USAGE"; /** What to do instead. Always present, always actionable — contract R8's shape. */ readonly fix: string; constructor(message: string, fix: string); } ``` ## Constants ### DEADLINE_ERROR_CODE A caller error, in the family's one vocabulary: a `code` that classifies it and a `fix` that says what to do instead. ```ts const DEADLINE_ERROR_CODE = "USAGE"; ``` ### DEFAULT_DEADLINE Two seconds: long enough to flush a file, short enough that nobody reaches for the keyboard. ```ts const DEFAULT_DEADLINE = 2000; ``` ### DEFAULT_PHASE Where a handler goes when its author did not say. ```ts const DEFAULT_PHASE: Phase; ``` ### EXIT_PATHS Every path, in the order `install()` wires them. Exported so a matrix can iterate it. ```ts const EXIT_PATHS: readonly ["exit", "beforeExit", "signal", "uncaught", "rejection"]; ``` ### PHASES The order shutdown happens in, as **data** rather than as registration order. ```ts const PHASES: readonly ["flush", "release", "restore"]; ``` ### SIGNALS The signals a CLI is expected to survive politely (design R1). ```ts const SIGNALS: readonly ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT", "SIGBREAK"]; ``` ## Interfaces ### Closeout ```ts interface Closeout { /** * Register a handler. Returns the function that unregisters it. */ onExit(handler: ExitHandler, spec?: HandlerSpec): () => void; /** Hide the cursor and register its restore; the returned function shows it again. */ hideCursor(stream: OutputStream): () => void; /** Show the cursor now. Idempotent, and a no-op on a non-TTY. */ showCursor(stream: OutputStream): void; /** The registry, for a caller that wants to drive shutdown itself. */ readonly registry: Registry; } ``` ### ExitEvent The shape an agent event carries: the same values, under the family's event key (Y6). ```ts interface ExitEvent { type: 'closeout.shutdown'; path: ExitPath; signal: string | null; code: number | null; error: string | null; timedOut: boolean; unfinished: string[]; } ``` ### ExitInfo What a trigger supplies. The path is inferred from the signal when it is not stated. ```ts interface ExitInfo { code: number | null; signal: string | null; path?: ExitPath; error?: unknown; } ``` ### ExitReport What a handler is handed. The same record `--json` and an agent event project from. ```ts interface ExitReport { /** Which door: a normal exit, an emptied loop, a signal, a throw, a rejected promise. */ path: ExitPath; /** The signal that ended it, when one did. */ signal: string | null; /** The code the process is leaving with, when it is leaving by a code. */ code: number | null; /** What was thrown or rejected, on the two paths that have one. `null` on the others. */ error: unknown; } ``` ### HandlerOptions How a handler is registered when a bare phase is not enough to say it. ```ts interface HandlerOptions { /** Which phase it runs in. Defaults to {@link DEFAULT_PHASE}. */ phase?: Phase; /** * What to call it in a report. */ label?: string; } ``` ### InstallOptions ```ts interface InstallOptions extends RegistryOptions { /** Defaults to the real `process`. */ process?: ProcessLike; } ``` ### ProcessLike The half of `process` this package needs, so the wiring can be tested without one. ```ts interface ProcessLike { on(event: string, listener: (...args: never[]) => void): unknown; removeListener(event: string, listener: (...args: never[]) => void): unknown; listenerCount(event: string): number; exit(code?: number): never; /** * Send a signal to a process. closeout passes exactly one pid — {@link ProcessLike.pid}, * its own — because the only thing it ever does with this is re-raise the signal it just * handled, so that a process killed by SIGINT *dies of SIGINT* instead of reporting 130. */ kill(pid: number, signal: string): unknown; /** This process's own id — the only one {@link ProcessLike.kill} is ever given. */ pid: number; stderr: OutputStream; /** * Optional because the signal wiring has never needed it and its tests do not supply one: * only the terminal-restore façade picks between the two streams. */ stdout?: OutputStream; /** * The code the process will leave with, which is a property of the process and not of any * handler. `exit-hook`'s contract is written in terms of it — a hook is handed the code the * program is about to exit with, and a signal overrules whatever was set — so the drop-in * path reads and writes it here. Typed as Node types it (`string` is legal and coerced). */ exitCode?: number | string | undefined; } ``` ### Registry ```ts interface Registry { /** * Register a handler in a phase. Returns the function that unregisters it. */ add(handler: ExitHandler, spec?: HandlerSpec): () => void; /** * Run every handler, once, phase by phase, inside the deadline. A later call runs nothing * and resolves with the first: it waits on a shutdown still in flight. */ run(info: ExitInfo): Promise; /** Run every handler synchronously, in phase order; a returned promise is abandoned. */ runSync(info: ExitInfo): ShutdownReport; /** How many handlers are registered — for a caller that wants to know if any are. */ readonly size: number; /** How many are registered in one phase. */ count(phase: Phase): number; /** Whether shutdown has already happened. */ readonly settled: boolean; /** * The report of the shutdown that happened, or `undefined` before one has. */ readonly report: ShutdownReport | undefined; } ``` ### RegistryOptions ```ts interface RegistryOptions { /** * How long shutdown may take before it stops waiting, in milliseconds. */ deadline?: number; /** Where a handler's own failure is reported. Defaults to stderr. */ onError?: (error: unknown) => void; /** * Where a breached deadline is reported. Defaults to stderr, naming every handler that * had not returned. Takes the whole report, so a caller can project it as JSON or as an * agent event instead of a line of prose. */ onTimeout?: (report: ShutdownReport) => void; } ``` ### ShutdownReport What shutdown *was*, once it has run: the record plus what the deadline found. ```ts interface ShutdownReport extends ExitReport { /** Whether the deadline expired before the handlers were done. */ timedOut: boolean; /** * The handlers that had not returned when shutdown stopped waiting — by the label their * caller gave them, or by the function's own `name`. */ unfinished: readonly string[]; } ``` ## Types ### ExitHandler A handler is handed the one record: `{ path, signal, code, error }` (design R2). ```ts type ExitHandler = (report: ExitReport) => void | Promise; ``` ### ExitPath Which door the process left by. ```ts type ExitPath = 'exit' | 'beforeExit' | 'signal' | 'uncaught' | 'rejection'; ``` ### HandlerSpec A phase, or the options object. The bare phase is the common case and stays spellable. ```ts type HandlerSpec = Phase | HandlerOptions; ``` ### Phase One of {@link PHASES}. ```ts type Phase = (typeof PHASES)[number]; ``` ## Re-exported Documented on the page of the entry point that declares them. | Export | Kind | Documented in | | :-- | :-- | :-- | | `once` | function | [`closeout/once`](/docs/api/once#once) | | `ENTER_ALTERNATE_SCREEN` | const | [`closeout/cursor`](/docs/api/cursor#enter_alternate_screen) | | `HIDE_CURSOR` | const | [`closeout/cursor`](/docs/api/cursor#hide_cursor) | | `LEAVE_ALTERNATE_SCREEN` | const | [`closeout/cursor`](/docs/api/cursor#leave_alternate_screen) | | `SHOW_CURSOR` | const | [`closeout/cursor`](/docs/api/cursor#show_cursor) | | `InputStream` | interface | [`closeout/cursor`](/docs/api/cursor#inputstream) | | `OutputStream` | interface | [`closeout/cursor`](/docs/api/cursor#outputstream) | --- # closeout/once > Every export of closeout/once, with its signature and doc comment: once. Source: https://closeout.interlace.tools/docs/api/once `once(fn)` — a function that runs at most once and returns its first result thereafter (design R5). ```ts import { once } from 'closeout/once'; ``` ## Functions ### once Wrap `fn` so it runs at most once. ```ts function once(fn: T): T; ``` | Parameter | Type | | :-- | :-- | | `fn` | `T` | **Returns** `T` --- # closeout/onetime > Every export of closeout/onetime, with its signature and doc comment: onetime, plus 2 types. Source: https://closeout.interlace.tools/docs/api/onetime `closeout/onetime` — the drop-in path for `onetime` 8 (`graded-override-targets` R3). ```ts import onetime from 'closeout/onetime'; import type { Options, OnetimeFunction } from 'closeout/onetime'; ``` ## Constants ### default The default export, declared as `onetime`. Ensure a function is called once; later calls return the first result. ```ts const onetime: typeof onceUntilReturned & { callCount: typeof callCount; }; ``` ## Interfaces ### Options Options onetime takes. ```ts interface Options { /** Throw when called more than once, instead of returning the first result. */ readonly throw?: boolean; } ``` ## Types ### OnetimeFunction A function wrapped by onetime: the same signature, called through the wrapper. ```ts type OnetimeFunction = (...arguments_: ArgumentsType) => ReturnType; ``` --- # closeout/plugin > Every export of closeout/plugin, with its signature and doc comment: validate, register, reset, registered, contributions, attach and 3 more, plus 5 types. Source: https://closeout.interlace.tools/docs/api/plugin The plugin host for closeout's half of the contract (`plugin-contract` R1, R5a, R6, R8). ```ts import { validate, register, reset, … } from 'closeout/plugin'; ``` ## Functions ### attach Hand every contributed handler to a closeout's registry, each in its own phase. ```ts function attach(host: HandlerHost): () => void; ``` | Parameter | Type | | :-- | :-- | | `host` | `HandlerHost` | **Returns** `() => void` ### contributions Every handler every registered plugin contributed, in the order they will run. ```ts function contributions(): Contribution[]; ``` **Returns** `Contribution[]` ### register Register a plugin. Later wins, like ESLint flat config — but "wins" is a weaker word here than it is for a colour token: handlers do not shadow each other, they accumulate. Order is kept because two handlers in the same phase run in it, and because a `plugin check` wants to print the list a reader will see. ```ts function register(plugin: unknown): void; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `void` ### registered The plugins registered, in registration order. ```ts function registered(): readonly Plugin[]; ``` **Returns** `readonly Plugin[]` ### reset Forget every registered plugin. For tests, and for a program that re-plugs at runtime. ```ts function reset(): void; ``` **Returns** `void` ### validate Refuse a plugin that cannot contribute a handler, at the door. ```ts function validate(plugin: unknown): asserts plugin is Plugin; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `asserts plugin is Plugin` ## Classes ### PluginError A refused plugin says what is wrong and what to do about it — the family's one vocabulary. ```ts class PluginError extends Error { readonly code: PluginErrorCode; readonly fix: string; constructor(code: PluginErrorCode, message: string, fix: string); } ``` ## Constants ### CONTRACT The plugin contract version. One number for the family — the same `1` flagstaff declares, written out rather than imported for the reason in the file comment above. ```ts const CONTRACT = 1; ``` ### PLUGIN_PHASES The phases a plugin may put a handler in. ```ts const PLUGIN_PHASES: readonly ["flush", "release"]; ``` ## Interfaces ### Contribution One contributed handler, with the plugin it came from and the phase it resolved to. ```ts interface Contribution { /** `":"` — the name a report prints. */ id: string; phase: Phase; from: string; run: ExitHandler; } ``` ### HandlerHost The half of a `Registry` this needs — declared structurally so a caller can attach to something it built itself, and so this file does not pull the process wiring in. ```ts interface HandlerHost { add(handler: ExitHandler, spec?: HandlerSpec): () => void; } ``` ### Plugin The keys closeout reads. Declared structurally: any object with these fields is a plugin here, whatever else it carries. ```ts interface Plugin { name: string; contract?: number; handlers?: readonly PluginHandler[]; } ``` ### PluginHandler One contributed handler: a name to report it by, a phase to order it by, and the work. ```ts interface PluginHandler { /** How the handler is named in a report — including the one the deadline prints. */ name: string; /** Defaults to `release`. Never `restore`; see {@link PLUGIN_PHASES}. */ phase?: (typeof PLUGIN_PHASES)[number]; /** The cleanup itself. May be async; the phase is awaited before the next one begins. */ run: ExitHandler; } ``` ## Types ### PluginErrorCode ```ts type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_PLUGIN_CONTRACT' | 'E_NO_CONTRIBUTION'; ``` --- # closeout/restore-cursor > Every export of closeout/restore-cursor, with its signature and doc comment: restoreCursor. Source: https://closeout.interlace.tools/docs/api/restore-cursor ```ts import restoreCursor from 'closeout/restore-cursor'; ``` ## Functions ### default The default export, declared as `restoreCursor`. Arrange for the cursor to be shown again however this process ends. ```ts function restoreCursor(): void; ``` **Returns** `void` --- # closeout/signal-exit > Every export of closeout/signal-exit, with its signature and doc comment: onExit, load, unload, signals. Source: https://closeout.interlace.tools/docs/api/signal-exit ```ts import { onExit, load, unload, … } from 'closeout/signal-exit'; ``` ## Functions ### load ```ts load: () => void; ``` ### onExit ```ts onExit: (handler: core.Handler, options?: core.Options) => () => void; ``` ### unload ```ts unload: () => void; ``` ## Constants ### signals ```ts signals: string[]; ``` --- # closeout/signal-exit/signals > Every export of closeout/signal-exit/signals, with its signature and doc comment: signals. Source: https://closeout.interlace.tools/docs/api/signal-exit/signals ```ts import { signals } from 'closeout/signal-exit/signals'; ``` ## Constants ### signals ```ts signals: string[]; ``` --- # Changelog > Every release of closeout, newest first, from its CHANGELOG.md — what changed and the pull request it came from. Source: https://closeout.interlace.tools/docs/changelog ## 1.1.10 ### Patch Changes - [#1483](https://github.com/ofri-peretz/burgee/pull/1483) [`3e69fb2`](https://github.com/ofri-peretz/burgee/commit/3e69fb2d8d068210347ac71d2886d6a4309859d1) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The package manifest names its author and publishes with `publishConfig` `{ "access": "public", "provenance": true }`. burgee's `homepage` is its landing page, https://burgee.interlace.tools. oxmutate gains `keywords`, so npm search finds it, and a `sideEffects` list (its CLI, its worker and its Vitest runner run on load), so a bundler can drop the rest. No code changes. ## 1.1.9 ### Patch Changes - [#1469](https://github.com/ofri-peretz/burgee/pull/1469) [`80fa4fb`](https://github.com/ofri-peretz/burgee/commit/80fa4fbdce8448adb1ad26bd0be95e5189284ba5) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `runSync` arriving while an asynchronous shutdown is still parked reports the handlers that never came back, and leaves that report on `registry.report`. Before, it answered `{ timedOut: false, unfinished: [] }` of its own and left `report` unset, so a lock still held in `flush` read as a shutdown that had finished cleanly. ## 1.1.8 ### Patch Changes - [#1258](https://github.com/ofri-peretz/burgee/pull/1258) [`e3b3a41`](https://github.com/ofri-peretz/burgee/commit/e3b3a41745ecdc853d741c457306c566541271b6) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` reads a `.json` plugin file as data. It handed every file to `import()`, and Node refuses a `.json` module without an import attribute (ERR_IMPORT_ATTRIBUTE_MISSING), so a plugin written as JSON got Node's message and no verdict, from the host's own `check` and through `burgee check`, which runs it. - [#1266](https://github.com/ofri-peretz/burgee/pull/1266) [`02e6f9f`](https://github.com/ofri-peretz/burgee/commit/02e6f9fcd779d892e7097659a6bd4c2f0e5b5a04) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family plugin schema (`schema.json`, identical in every plugin host) now describes `formats`, burgee's output-format key. This package reads nothing new, and a plugin carrying `formats` registers here as before. - [#1252](https://github.com/ofri-peretz/burgee/pull/1252) [`efc994e`](https://github.com/ofri-peretz/burgee/commit/efc994ea20dae1f31990a7afef9dfa3c34fee596) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The README names the Node builtin the package builds on, and says what the package adds over it: `Intl.Segmenter` and `util.stripVTControlCharacters` (linegauge), `process.loadEnvFile` and `util.parseEnv` (seniority), `node:child_process` (bellpull), `process.on(signal)` (closeout). The README's opening names the package and its incumbents only, and every benchmark figure in it links to where it is measured. ## 1.1.7 ### Patch Changes - [#1198](https://github.com/ofri-peretz/burgee/pull/1198) [`86b97b3`](https://github.com/ofri-peretz/burgee/commit/86b97b35bfbe4aa09858b7814704741beabf166c) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `closeout/exit-hook` and `closeout/signal-exit` now bundle lighter than the packages they replace. `closeout/exit-hook` is 1,579 B against exit-hook 5.1.0's 1,645 B, down from 4,819 B, and `closeout/signal-exit` is 2,657 B against signal-exit 4.1.0's 2,856 B, down from 3,634 B (esbuild, minified, one imported name). `closeout/exit-hook` no longer runs on closeout's registry. It runs exit-hook's own shutdown, so it now behaves as exit-hook does in three places it did not before: it prints nothing when `max(wait)` runs out, its hooks are not ordered against closeout's own `restore` phase, and a hook that throws is not caught. closeout's `onExit` keeps all three. `closeout/signal-exit` gains an `import` build beside its CommonJS one, as signal-exit ships both: `import` loads an ES module and `require()` the CommonJS file, over the same code and one shared emitter. Grades are unchanged: exit-hook 21 / 21, and signal-exit level with signal-exit itself. ## 1.1.6 ### Patch Changes - [#1186](https://github.com/ofri-peretz/burgee/pull/1186) [`af24328`](https://github.com/ofri-peretz/burgee/commit/af243288e27cbffbcffac18af7112172e46a003b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Published declarations now keep each doc comment's first paragraph, its code examples and its `@param`, `@returns`, `@throws`, `@example`, `@deprecated`, `@default` and `@see` tags, and drop the longer design notes below them, so the tarballs are smaller (burgee by 5.7% gzipped, bellpull and closeout by about a fifth). Types are unchanged, and hover text in an editor keeps its summary. `burgee`'s README also names `burgee/program-schema.json`, the JSON Schema every `--schema` document validates against. ## 1.1.5 ### Patch Changes - [#1160](https://github.com/ofri-peretz/burgee/pull/1160) [`0b381a0`](https://github.com/ofri-peretz/burgee/commit/0b381a0d394092edf2ea21a9143a10111aa72fe4) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Export `./package.json`. `require('/package.json')` threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, which broke tools that read a dependency's manifest that way: bundlers, license scanners and version checks. ## 1.1.4 ### Patch Changes - [#1130](https://github.com/ofri-peretz/burgee/pull/1130) [`c05c86e`](https://github.com/ofri-peretz/burgee/commit/c05c86e2caa0fc3a89f97f7fe9c37bd7b876bfaf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every drop-in subpath now type-checks under `moduleResolution: node10`, and under `module: commonjs` with no resolution set, which is TypeScript's default there. `import … from 'burgee/commander'` used to fail with TS2307 in such a project, although `require()` of the same specifier worked. Each package now declares `types` and a `typesVersions` map, derived from `exports`, with one key per typed subpath pointing at the file `exports` already names. `exports` is unchanged, so `node16`, `nodenext` and `bundler` read exactly what they read before. `controlroom/ink`'s declarations no longer name the untyped `react-reconciler`, which was TS7016 for consumers with `skipLibCheck: false`. ## 1.1.3 ### Patch Changes - [#1046](https://github.com/ofri-peretz/burgee/pull/1046) [`1797388`](https://github.com/ofri-peretz/burgee/commit/1797388f27a294803b12839ff4d074226bd40917) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `rawMode` keeps the terminal raw until the last caller that turned it on through `rawMode` lets go, in any order. A second caller — a prompt opened while a spinner discards stdin — used to get a no-op, so the spinner stopping first turned raw mode off under the prompt. A mode turned on by anyone else is still left alone. ## 1.1.2 ### Patch Changes - [#1045](https://github.com/ofri-peretz/burgee/pull/1045) [`c4fd9a3`](https://github.com/ofri-peretz/burgee/commit/c4fd9a3cb10bc36bef8c468365addfc6deb572cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family plugin schema (`schema.json`, identical in every plugin host) now describes `locales`, burgee's locale-pack key. A plugin carrying `locales` validates against the shared schema as it does against `burgee/locale-pack-schema.json`, which lists every message id. This package reads nothing new, and a plugin carrying `locales` registers here as before. ## 1.1.1 ### Patch Changes - [#1022](https://github.com/ofri-peretz/burgee/pull/1022) [`ecb0a00`](https://github.com/ofri-peretz/burgee/commit/ecb0a00ca68e9a3b3584aefabb9dcf97e3ff967a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The tarball no longer carries declaration files that no `exports` entry reaches. TypeScript could never open them, because a deep import is refused by `exports`. Every entry's types are unchanged. The ten packages drop 71 files and 118,312 bytes between them, burgee 35 files and 76,504 bytes, which takes its tarball from 251,814 to 225,278 bytes gzipped. ## 1.1.0 ### Minor Changes - [#1005](https://github.com/ofri-peretz/burgee/pull/1005) [`079250e`](https://github.com/ofri-peretz/burgee/commit/079250ebc3e0cc511df1269060854711f686b5b4) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Two new drop-in subpaths, each graded by its incumbent's own suite and level with it. `closeout/onetime` is onetime 8 (6 of 6): the default export, `callCount` and `{ throw: true }`, and like onetime it retries a first call that throws. `closeout/cli-cursor` is cli-cursor 5 (8 of 8): `{ show, hide, toggle }` on `process.stderr` by default, and a hide arranges the show at exit through `closeout/restore-cursor`. `burgee migrate` now rewrites `onetime` and `cli-cursor`. `closeout/restore-cursor` bundles 318 B less (4,648 to 4,330 B on /docs/weight): the process-wide `onExit` reaches the registry without the `Closeout` view, which carried the cursor pairing. ## 1.0.4 ### Patch Changes - [#938](https://github.com/ofri-peretz/burgee/pull/938) [`9babb6d`](https://github.com/ofri-peretz/burgee/commit/9babb6da1ae7783c7e8367f89e3da9dffc85ffdb) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - npm description and README: the description says what the package does, then its dependency fact in one of two wordings ("Zero dependencies." or "No dependency outside the burgee family."), then its drop-in paths. The README's family section states how the family is built — the leaves, one-way dependencies, nothing from outside the family, trusted publishing with provenance — and its table column is "Migrates from". No API, compatibility grade or benchmark changes. ## 1.0.3 ### Patch Changes - [#917](https://github.com/ofri-peretz/burgee/pull/917) [`735f0f6`](https://github.com/ofri-peretz/burgee/commit/735f0f68048f2d0f5e4effad151ed5132b480bb8) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: the opening paragraph says what the package does first. The drop-in path follows it as one line, naming the incumbent and `npx burgee migrate`, and every compatibility row and benchmark is unchanged. ## 1.0.2 ### Patch Changes - [#897](https://github.com/ofri-peretz/burgee/pull/897) [`f29f159`](https://github.com/ofri-peretz/burgee/commit/f29f159ca0c9746f58d8ba84eec9be72b1140093) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom has its own docs site at https://controlroom.interlace.tools: its README's Docs line, its `homepage`, and every family table now link there. ## 1.0.1 ### Patch Changes - [#848](https://github.com/ofri-peretz/burgee/pull/848) [`6ae533e`](https://github.com/ofri-peretz/burgee/commit/6ae533e4610c32bad557f5d74a24d0c9a0f07f7e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` describes what five values look like. `contract` is `1`, and burgee refuses a plugin that declares none. A caique widget's `static(spec)` gets `{ kind, message, ...sample.done }`. A seniority `rank` sits between the built-in layers at flag 0, environment 10, config file 20, `package.json` 30 and default 40. A burgee hook's `filter` is `{ command: RegExp }`, now with `command` required, so the schema and the host refuse the same filters. - [#857](https://github.com/ofri-peretz/burgee/pull/857) [`d80b2d0`](https://github.com/ofri-peretz/burgee/commit/d80b2d0f4a535400c6825b42ac2d8e1be2827f4e) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The family `schema.json` closes a bellpull resolver's `when` with `additionalProperties: false`, so the schema and bellpull's own validation refuse the same unknown keys. ## 1.0.0 ### Major Changes - [#830](https://github.com/ofri-peretz/burgee/pull/830) [`1945d65`](https://github.com/ofri-peretz/burgee/commit/1945d65f22aafabd380d67f1e40341d6ef3aa6fa) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - closeout 1.0.0. The spec is fully built, every drop-in passes its incumbent's own suite at the latest release level with the incumbent itself, and coverage is 100% on all four measures (D-20261006-foundation-1-0-evidence). 1.0 makes the published entries, the plugin `schema.json` and the `closeout` bin a semver contract. No API changed in this release. ## 0.7.0 ### Minor Changes - [#816](https://github.com/ofri-peretz/burgee/pull/816) [`5230016`](https://github.com/ofri-peretz/burgee/commit/52300169a98ece41b3544e5316833464e63a7c83) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom hosts an input line inside a screen (R20): `open(rt, { input: { editor, pane, onSubmit } })` routes keys to caique's line editor while its pane has focus and lets unused keys fall through to the keymap; outside a terminal, entries come from piped stdin, never a wait. closeout gains `bracketedPaste()` in `closeout/cursor`, paired with its restore like the alternate screen. ### Patch Changes - [#816](https://github.com/ofri-peretz/burgee/pull/816) [`5230016`](https://github.com/ofri-peretz/burgee/commit/52300169a98ece41b3544e5316833464e63a7c83) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - controlroom hosts plugins (R10): `keymaps` and `panes` register through `controlroom/plugin`'s `register()` against the family schema, a screen takes either by name, and `controlroom check ` reports what a plugin contributes. The family schema every host ships gains the `keymaps` and `panes` definitions. ## 0.6.1 ### Patch Changes - [#752](https://github.com/ofri-peretz/burgee/pull/752) [`22e6dae`](https://github.com/ofri-peretz/burgee/commit/22e6dae1b71bfa478265829a1b98d20a6f9de4f7) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - A plugin that declares `contract: 0` or a negative contract is now refused with `E_PLUGIN_CONTRACT`, as `schema.json`'s minimum of 1 always said. These hosts checked only that a contract was not newer than the one they know, so 0 and below registered. ## 0.6.0 ### Minor Changes - [#662](https://github.com/ofri-peretz/burgee/pull/662) [`ff4159d`](https://github.com/ofri-peretz/burgee/commit/ff4159d25544fd45257f145e40ff0c5f8dfc4b3a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Terminal restore now covers all three things design R4 promises: raw mode off, alternate screen left, cursor shown — last, on every exit path. - `alternateScreen(stream)` enters the alternate screen (`ESC[?1049h`) and registers leaving it in the `restore` phase; the returned function leaves it early. `rawMode(input)` turns raw mode on and registers turning it off; an input that was already raw belongs to somebody else and is left alone, now and at exit. Both sit beside `hideCursor`, run their undo at most once, and write nothing to a non-TTY. They register on the process-wide instance, or on one `install()` returned when passed as a second argument, and tree-shake away from programs that do not import them. `closeout/cursor` exports the leaf forms and `ENTER_ALTERNATE_SCREEN` / `LEAVE_ALTERNATE_SCREEN`. - Fixed: a process whose shutdown was waiting on a handler that holds nothing in the event loop could leave through `'exit'` before the deadline without ever running the `restore` phase. Node's `'exit'` now invokes every phase the shutdown had not reached yet, each still exactly once. ### Patch Changes - [#729](https://github.com/ofri-peretz/burgee/pull/729) [`6d8aadf`](https://github.com/ofri-peretz/burgee/commit/6d8aadff01a4c65ef3be16fe2082b0abd0c00b90) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Two fixes, and some code no input could reach is removed. `closeout/exit-hook` no longer calls asynchronous hooks on a synchronous exit. On `process.exit()` or PM2's `shutdown` message it printed exit-hook's notice that asynchronous tasks "will not run", then called every `asyncExitHook` callback anyway and abandoned the promise. Any code a hook ran before its first `await` therefore ran under closeout and not under exit-hook. Only the synchronous hooks run on that path now, as they do upstream. A handler registered with a phase that is not `flush`, `release` or `restore` is refused with `TypeError: closeout: no phase `. Before, a misspelled phase from untyped code, such as `onExit(unlock, 'Restore')`, was accepted, never counted and never run. `count()` refuses the same names. `closeout check` no longer carries a "(replaces …)" helper it never called, or a filter that could never drop a row, because it loads one plugin into an emptied registry. A fallback exit code for a signal outside the five closeout listens on is gone, because there is no such signal. `closeout/exit-hook` also drops a per-event once-guard that duplicated the registry's own, plus two fields of a record no code read. - [#670](https://github.com/ofri-peretz/burgee/pull/670) [`5e635b0`](https://github.com/ofri-peretz/burgee/commit/5e635b0a79c16ff2b459a4d00c80d334d5d945d1) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Fixed: a second signal during shutdown killed the process before the terminal was restored. A second Ctrl-C, or a SIGTERM that arrived while a handler was still running, left the terminal in raw mode, on the alternate screen, with the cursor hidden. A second trigger now waits for the shutdown already running, so the terminal is restored first and the process dies of the first signal. The deadline still bounds the wait. - [#662](https://github.com/ofri-peretz/burgee/pull/662) [`ff4159d`](https://github.com/ofri-peretz/burgee/commit/ff4159d25544fd45257f145e40ff0c5f8dfc4b3a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Fixed: a process whose shutdown was waiting on a handler that holds nothing in the event loop exited 0 instead of leaving the way its trigger decided. SIGTERM through `install()` now dies of SIGTERM, an uncaught throw exits 1 and prints the error, and `closeout/exit-hook` exits 143 on SIGTERM, as `exit-hook` does. The deadline now keeps the event loop alive until the shutdown finishes or times out, on every trigger except `'beforeExit'`. When it times out, the breach report names the handler that hung. - [#674](https://github.com/ofri-peretz/burgee/pull/674) [`e9f45d8`](https://github.com/ofri-peretz/burgee/commit/e9f45d85d9db5b2e3dcaa1e43f292a1281a6952a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: family header, badges, install, migrating, the family table. Every package README now opens the same way — lockup, tagline, one badge row in one order (npm version, downloads, Quality Gate, the package's own coverage, OpenSSF Scorecard, unpacked size, dependencies, types, Node, licence, npm provenance), a row of compatibility badges read from the graded baseline — and carries the same sections in the same order: Install for npm, pnpm, yarn and bun, Quick start, Migrating as a before/after diff, Compatibility, Benchmarks, For agents, API, and a generated table of the nine packages. Links are absolute, so they work on npm as well as GitHub. ## 0.5.4 ### Patch Changes - [#627](https://github.com/ofri-peretz/burgee/pull/627) [`4a629a9`](https://github.com/ofri-peretz/burgee/commit/4a629a9ff1129f2ce6f0c34e7cfc1d159304d18a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Lint with every published Interlace ESLint plugin, and fix what the upgrade surfaced. - caique: the inquirer theme merge skips `__proto__`, `constructor` and `prototype` keys, so a theme object cannot swap the merged object's prototype. - burgee: last-element reads use `.at(-1)`. - burgee, closeout, flagstaff, roundel: helpers that capture nothing from their enclosing function move to module scope. - seniority: suppression comments name the `no-dynamic-require` rule that now reports the config loader's dynamic `require`. No public API or output changes. ## 0.5.3 ### Patch Changes - [#604](https://github.com/ofri-peretz/burgee/pull/604) [`0e7b1e8`](https://github.com/ofri-peretz/burgee/commit/0e7b1e88a7021350cf689f109c7728f799be1589) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README links to its migration guides under the docs link: "Migrating from: chalk", "ora · log-update · boxen · cli-table3", and so on. That puts a path from the npm page to the guide for the library you are replacing. No code changes. ## 0.5.2 ### Patch Changes - [#518](https://github.com/ofri-peretz/burgee/pull/518) [`866b972`](https://github.com/ofri-peretz/burgee/commit/866b9724652bebea867a730b8f2ea5e0ca63f5ab) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README weight lines now count every incumbent with a graded drop-in: terminal-link and term-img (paratext), exit-hook (closeout), @inquirer/core (caique). - [#474](https://github.com/ofri-peretz/burgee/pull/474) [`1955419`](https://github.com/ofri-peretz/burgee/commit/19554194342b55f8893161f894a8c2a4df1b0f21) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - burgee plugins can hook two more stages. `parse` runs before the command is resolved: it receives argv and may return a replacement, which is how an alias plugin maps `d` to `deploy`. `shutdown` runs once as the program exits, whether the command succeeded or failed. The family `schema.json` shipped in every package now describes both stages. - [#492](https://github.com/ofri-peretz/burgee/pull/492) [`1b002e0`](https://github.com/ofri-peretz/burgee/commit/1b002e0b72f8dc1aa61020fb40c26e50949f16e7) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The README no longer prints an `overrides` recipe: an override points an incumbent's name at closeout's root, which is closeout's own API, so `exit-hook` and `restore-cursor` never linked through it. Each drop-in — now including `closeout/signal-exit`, graded 134 / 135 by signal-exit's own suite — is swapped by import. - [#519](https://github.com/ofri-peretz/burgee/pull/519) [`77ff1cb`](https://github.com/ofri-peretz/burgee/commit/77ff1cb8e595d32bb8bddf441ae21fc61e6f247d) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The drop-ins now export their incumbents' type names, so a TypeScript program migrates by its import alone: `roundel/chalk` gains chalk's `Color`, `ForegroundColor`, `BackgroundColor`, `Modifiers` and `Options`; `flagstaff/ora` gains `Spinner`, `PrefixTextGenerator` and `SuffixTextGenerator`; `flagstaff/boxen` gains `Options`, `CustomBorderStyle` and `Boxes`; `flagstaff/log-update`, `linegauge`, `linegauge/wrap` and `closeout/exit-hook` gain `Options`; `burgee/yargs/parser` gains `Arguments`, `Options` and `Configuration`. Types only — no runtime bytes. ## 0.5.1 ### Patch Changes - [#508](https://github.com/ofri-peretz/burgee/pull/508) [`1aae1e2`](https://github.com/ofri-peretz/burgee/commit/1aae1e2186ce88421067df5317795773419e53d0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - ` --help` and `--version` answer instead of crashing. The bin took its first argument as the plugin file to import, so `roundel --help` failed with `Cannot find module '…/--help'` and exit 1. `-h`/`--help` now print usage and exit 0, `-V`/`--version` print the version and exit 0, and any other flag where the plugin file belongs is a usage error, exit 2. ## 0.5.0 ### Minor Changes - [#507](https://github.com/ofri-peretz/burgee/pull/507) [`b8e97dc`](https://github.com/ofri-peretz/burgee/commit/b8e97dcb64772e413f0b6f9e17e063c73314d242) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Runs on Node 20 and 22, not just 24+: `engines.node` is now `^20.19.0 || >=22.13.0`. Those are the first releases where `require(esm)` loads without a warning, so the CommonJS `require()` path keeps working. Every package's test suite runs on exactly 20.19.0 and 22.13.0, on Linux, macOS and Windows. caique's prompts no longer call `Promise.withResolvers`, which Node 20 doesn't have. ### Patch Changes - [#505](https://github.com/ofri-peretz/burgee/pull/505) [`9800b43`](https://github.com/ofri-peretz/burgee/commit/9800b43d9c74a49dfb66d04a40fd0d1c48892e20) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Docs: the Benchmarks section's weight ceiling is re-measured against a fresh install of each incumbent's latest release (cosmiconfig 10.0.1, slice-ansi 9.0.1, which 7.0.0, dotenv 18.0.3, …) instead of the copies hoisted in this workspace, and names incumbents that were measured but left out of the ceiling as exactly that. ## 0.4.2 ### Patch Changes - [#501](https://github.com/ofri-peretz/burgee/pull/501) [`a4a5c43`](https://github.com/ofri-peretz/burgee/commit/a4a5c43269d2aeab80b69ab53bc48b2108bca866) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The README's exit-hook / restore-cursor override examples resolve to the current release (`^0.4`). ## 0.4.1 ### Patch Changes - [#494](https://github.com/ofri-peretz/burgee/pull/494) [`f7f6d4b`](https://github.com/ofri-peretz/burgee/commit/f7f6d4b8e8f9d9c7010bd4c81fda4b4d106fc9f0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each package's `homepage` and README docs link now point at its own documentation site, `https://.interlace.tools`, instead of a page on burgee's site. The old `burgee.interlace.tools/docs/packages/` URLs answer with a 301 to the new host, so nothing already linked breaks. closeout's README override example also resolves to the current release again (`npm:closeout@^0.4`; the 0.4.0 release left it at `^0.3`). ## 0.4.0 ### Minor Changes - [#459](https://github.com/ofri-peretz/burgee/pull/459) [`69563d1`](https://github.com/ofri-peretz/burgee/commit/69563d1fb14d9a4b29364446bae2fc48d86f6103) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `closeout/signal-exit` and `closeout/signal-exit/signals` — the drop-in path for `signal-exit` 4 (198.9 M/wk), graded 134 / 135 by signal-exit's own test suite, the same case its own package fails. `onExit`, `load`, `unload` and `signals`, at 4,797 B against the incumbent's 10,995 B. CommonJS on purpose — the suite re-evaluates the module under a changed `process` — and importable by name from ESM. ### Patch Changes - [#480](https://github.com/ofri-peretz/burgee/pull/480) [`2dc573f`](https://github.com/ofri-peretz/burgee/commit/2dc573f884e7a4cc46829cd8f2c949a17f07710c) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README corrections: paratext shows terminal-link at its measured 8 / 10 (was the stale 0 / 10 floor); linegauge's and closeout's `npm:` override examples resolve to the current release instead of 0.2 / 0.1. ## 0.3.2 ### Patch Changes - [#465](https://github.com/ofri-peretz/burgee/pull/465) [`acf98f3`](https://github.com/ofri-peretz/burgee/commit/acf98f3e612c6d79e6c2b78a847abcd06a063cbc) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README now opens with the incumbent it replaces and the agent surface it serves (`--json`, an agent event, or a static projection), so npm shows both above the fold. README text only; no code changed. ## 0.3.1 ### Patch Changes - [#454](https://github.com/ofri-peretz/burgee/pull/454) [`b4584e7`](https://github.com/ofri-peretz/burgee/commit/b4584e719bc0064b294aab5ea6da1c11f698f0e9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package's npm `homepage` now points at its page on the docs site, `https://burgee.interlace.tools/docs/packages/`, and each README links it under the header. The keywords add what people and models search for: `burgee` gains `cli-framework`, `argument-parser`, `subcommands`, `json-schema`, `mcp-server`, `model-context-protocol`, `ai-agent`, `llm`, `shell-completion`, `typescript`, `zero-dependency`, `commander-alternative` and `yargs-alternative`; the other eight gain `agent`, `ai-agent`, `non-tty`, `json` and `zero-dependency` where the package does that — `zero-dependency` only on the six that install nothing at all. `burgee`'s README gains a short FAQ (commander alternative, agent use, MCP, dependencies) and states the compatibility counts the oracle holds — 1,360 / 1,360 of commander's tests and 804 / 804 of yargs' — where it had said 1,215 and 1,185. `caique`'s README no longer calls a released package pre-release. - [#442](https://github.com/ofri-peretz/burgee/pull/442) [`bdaf364`](https://github.com/ofri-peretz/burgee/commit/bdaf364f81564c1700cf1adec18f927afe6c60c9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now lists `plugin`, `plugins` and `extensible` in its npm keywords, because every package takes plugins through one shared contract. A plugin is a plain object, validated against the `schema.json` that ships in every package, and checked with the package's own `check` command. Each package reads its own key and ignores the rest, so one object can extend any subset of the family. The [plugins page](https://github.com/ofri-peretz/burgee/blob/main/apps/docs/content/docs/plugins.mdx) has a nine-layer example that every package's `check` accepts in CI. - [#435](https://github.com/ofri-peretz/burgee/pull/435) [`7888524`](https://github.com/ofri-peretz/burgee/commit/78885245eb292cd4a40541fe09382a198c9c45cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` now describes every plugin host in the family. The one schema each package ships as its plugin contract used to cover only four hosts: roundel's `tokens`, flagstaff's `glyphs`, `spinners`, `borders` and `components`, paratext's `capabilities`, and linegauge's `widths`. Five hosts validated their keys in their own code, but the file an author (or a model) writes against said nothing about them. It now describes all of them: - bellpull `resolvers`, including the absolute-path rule on `paths` - caique `widgets` - closeout `handlers`, including the phases a plugin may use - seniority `sources`, including the rank bounds - burgee `commands`, `hooks` and `enforce` Where the schema can express a rule, it gives the same verdict as the host's own validator, and a test holds the two together. Function-valued fields (`static`, `run`, `read`, `handler`) are described and required, but not typed, because JSON Schema can't say "function". **flagstaff** now validates a plugin against only its own keys, not the whole family schema. It no longer refuses a plugin over another host's key, which lets one plugin object contribute to several hosts. Its entry points are also 4.7–5.9 KB lighter for it. - [#445](https://github.com/ofri-peretz/burgee/pull/445) [`dac303e`](https://github.com/ofri-peretz/burgee/commit/dac303e944e889ac4175ac38c94e4ca0f0ca5358) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now declares `sideEffects` truthfully, so bundlers can drop what you don't import. Six packages declared nothing, so no bundler could drop any of their modules. A named import from the root now bundles to the same bytes as the same import from its subpath: | import | before | after | | :------------------------------------ | ------: | ------: | | `import { explain } from 'seniority'` | 2,939 B | 1,067 B | | `import { decide } from 'caique'` | 1,235 B | 734 B | | `import { strip } from 'linegauge'` | 1,102 B | 940 B | | `import { once } from 'closeout'` | 353 B | 235 B | flagstaff and roundel used to declare `false`, but each ships a `check` command whose file runs when loaded. Each now lists that file, which is the true statement. paratext also lists the two modules that register its built-in capabilities when they load. ## 0.3.0 ### Minor Changes - [#421](https://github.com/ofri-peretz/burgee/pull/421) [`db3c59e`](https://github.com/ofri-peretz/burgee/commit/db3c59e3dcd373c7e6e4a057715adb173766523f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every plugin host has a `check` command. ```bash npx linegauge check ./my-widths.mjs npx burgee check ./my-plugin.mjs --json ``` PRINCIPLES 7 asks three things of an extension surface: the plugin is data validated against one published schema, there is a **`check` command that shows it every way it can be seen**, and the bar is measured. The first was built in all nine hosts; the second existed in `flagstaff` alone. So an author writing a plugin for any other host found out what it did by shipping it into a program — and a surface nobody can check is a surface nobody outside this repository can write against. Each command validates, registers, and shows what the host does with the plugin, in the host's own terms: linegauge measures each code point **before and after** the override, paratext shows a capability's `encode` **and** its `fallback`, roundel each token and what it replaced, caique each widget's static projection rendered with its own sample. burgee's returns a **document** rather than printing one, so `burgee check --json` is the form an agent that just wrote a plugin reads. They share one contract with the author, held identically across all nine: - a readable report, contribution by contribution, with **`ok` as the last line**; - a refusal with a code from the family's vocabulary and a `fix`, exit 1; - **`E_NO_CONTRIBUTION`** for a plugin that contributes nothing to this host — the schema allows unknown keys so one object registers everywhere, which makes a misspelled key silent, and this is how that typo tells on itself; - exit 2 with no file. Each host also gains an eval case measuring the one-turn claim, proved to discriminate before it was committed: green against a correct plugin, red against the same plugin with one field broken. ### Patch Changes - [#430](https://github.com/ofri-peretz/burgee/pull/430) [`4d1b2b3`](https://github.com/ofri-peretz/burgee/commit/4d1b2b399cff354864d1e2e843a19fde80ef1f30) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` now reports every refusal with its code and its fix, wherever it was raised. Some plugin files register themselves on import: they call `register()` at the top of the module and export the result. Until now, when such a file was refused, the error was thrown inside `check`'s `import()`, before the only `try` that turns a `PluginError` into `E_PLUGIN_SCHEMA: …` plus a `fix:` line. The author got the bare message on stderr, with no code and no fix. Now the whole of `check` runs inside that one handler, so every refusal comes out the same way on every host. ## 0.2.1 ### Patch Changes - [#373](https://github.com/ofri-peretz/burgee/pull/373) [`a1f1d40`](https://github.com/ofri-peretz/burgee/commit/a1f1d40b7d1e7244f3a180943b641bb3b491d256) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Stage 2's artifact is now `spec.md`, the name Anthropic's AI-Native SDLC playbook gives it, so the source comments and README sections that cite a package's own design document point at `spec.md` rather than `design.md`. No behaviour changes. The published tarballs do move, by two bytes per surviving reference — `design.md` is nine characters and `spec.md` is seven — so the four packages carrying a weight band were re-measured against it: linegauge 83,538 to 83,536; paratext 66,343 to 66,341; closeout 84,455 to 84,453; bellpull 86,113 to 86,107. ## 0.2.0 ### Minor Changes - [#316](https://github.com/ofri-peretz/burgee/pull/316) [`c8acb28`](https://github.com/ofri-peretz/burgee/commit/c8acb2848714a1cbe3e26a2f9895d7498fb4f096) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Shutdown is now bounded on every path out of a program, and a breach says which handler did not come back. **The deadline reports.** When the clock expires the process still leaves — with the code it was already leaving with — and the report names **every handler that had not returned**, by the caller's `label`, the function's own `name`, or `(anonymous)`. A plugin's handlers are named `":"`. `run()` resolves with that report (`{ path, signal, code, error, timedOut, unfinished }`), and `install({ onTimeout })` is where the line goes; the default writes it to stderr. A hang used to be silence; it is now a diagnosable event with a name in it. **`Infinity` and `0` are refused** at `install()` / `createRegistry()` — not at the shutdown they would have ruined — with a `USAGE`-class error carrying a `fix`. One waits forever; the other gives no asynchronous handler a turn. Both reintroduce the failure the package exists to remove. **Three more doors.** `beforeExit`, `uncaughtException` and `unhandledRejection` now run the handlers, and `SIGNALS` gains `SIGQUIT` and `SIGBREAK`. No incumbent in this layer listens for a throw or a rejection, which is why a CLI that crashes mid-render leaves the cursor hidden. A program with its own crash handler keeps deciding what happens next — closeout stands its own listener down and counts before exiting, exactly as it already did for signals. **One record, three renderings.** A handler is handed `{ path, signal, code, error }`, where `path` is `'exit' | 'beforeExit' | 'signal' | 'uncaught' | 'rejection'`, and `reportToJson()` and `reportToEvent()` project that same value for a `--json` line and an agent event. **`once(fn)`** arrives as `closeout/once`: `onetime` + `mimic-fn` — 262 M downloads a week between them — in 441 B with no dependency, preserving `name`, `length` and `this`. `closeout/cursor` is the other new leaf, 666 B, for a program that only needs to put a cursor back. **The exit code is the program's.** It is captured at the trigger, before any handler runs, so a handler that sets `exitCode = 0` on its way past cannot turn a `process.exit(3)` or a SIGTERM into a success. Existing callers are unaffected: `onExit(handler)` and `onExit(handler, 'flush')` both still work, and the handler's argument gained fields rather than losing any. The published bundle got _smaller_ — comments are now stripped from `dist/`, 46,066 B down to 20,417 B — and every entry point is on a byte ratchet. - [#303](https://github.com/ofri-peretz/burgee/pull/303) [`fc640dd`](https://github.com/ofri-peretz/burgee/commit/fc640ddec11255c12d1e0948c5b8e99cc3f3263b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Two graded drop-in paths: `closeout/exit-hook` and `closeout/restore-cursor`. Both are new subpath exports, and both are graded by the incumbent's own unedited test suite through `compat-oracle`, with `--control` — the same suite run against the incumbent itself — proving the gate first: `exit-hook@5.1.0` 21 / 21 against a 21 / 21 control, and `restore-cursor@5.1.0` 6 / 6 against a 6 / 6 control. `overrides: { "exit-hook": "npm:closeout@^0.1" }` and the same for `restore-cursor` now resolve. `closeout/exit-hook` keeps the incumbent's per-hook `{ wait }` bound rather than imposing closeout's own 2 000 ms deadline — the incumbent's own suite registers a hook with `wait: 2000`, and a drop-in that silently tightens a caller's timeout is not a drop-in. closeout's bounded shutdown stays in `onExit()`. Nothing a caller already imports changed. Internally the signal wiring moved from `index.ts` to `install.ts` and the guarded `globalThis.process` lookup to `ambient.ts`, so the two façades can reach them without importing the package's own entry; `index.ts` re-exports every name it exported before. - [#294](https://github.com/ofri-peretz/burgee/pull/294) [`3f92a60`](https://github.com/ofri-peretz/burgee/commit/3f92a6099b1b8d5d476c64405ca963d40bf9af45) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Shutdown order is now data rather than registration order, and `closeout/plugin` hosts `handlers`. A handler declares a **phase** — `flush`, `release` (the default) or `restore` — and `PHASES` declares the sequence. Phases run _in sequence_, so an async handler in `flush` settles before `release` begins; handlers inside one phase run together, in registration order. Terminal restore moves into `restore` and is therefore last, always. This closes a failure that registration order could not: the cursor's restore was registered by whichever renderer hid the cursor, usually the moment it first drew, so anything registered afterwards ran _after_ the terminal had already been handed back — cleaning up nothing it was registered to clean up. An order that depends on import order is not an order. `closeout/plugin` is the new subpath (`plugin-contract` R5a): `register()` keeps a plugin's `handlers` and ignores every other layer's keys, `attach(registry)` wires each into its phase, and `contributions()` projects the whole shutdown sequence without running any of it. A plugin may use `flush` or `release` and not `restore` — R5a says a plugin's cleanup runs "never after" terminal restore, and that is enforced at the door rather than asserted in prose. `closeout/schema.json` is exported too, because it is the specifier this package's own `E_PLUGIN_SCHEMA` fix names. Past the deadline, later phases are still **run** — they are only no longer waited for. A handler that hangs in `flush` does not get to decide that the cursor stays hidden. Existing callers are unaffected: `onExit(handler)` still works and lands in `release`, which is before `restore`. - [#332](https://github.com/ofri-peretz/burgee/pull/332) [`3ea38c3`](https://github.com/ofri-peretz/burgee/commit/3ea38c363b9f0cee9f1c1c2dae4037b047e98328) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `closeout` re-raises a signal instead of exiting `128 + n`, so a process killed by Ctrl-C now really dies of SIGINT rather than exiting 130. `WIFSIGNALED` is false for the old behaviour and true for the new one, and that difference is visible to everything upstream of the program: a shell knows to print `^C` and re-raise into its own job control, `make` stops a parallel build instead of carrying on, a CI runner marks a job cancelled rather than failed, and a supervisor decides whether to restart. 128 + n is the number a shell reports _afterwards_; it was never a status a process could set for itself. The package used to argue the opposite in a comment — that re-raising "re-enters this listener", and that exiting explicitly is what a caller who owns `main` wants. The first half was already answered by the code around it: closeout removes its own listener _before_ it counts, which is the same `unload()`-then-`process.kill(process.pid, sig)` shape `signal-exit` uses, and `signal-exit` is closeout's declared incumbent for this surface. The second half is a preference the incumbent does not share. The stand-down guard is unchanged and now covers one more thing: the re-raise happens only when no other listener remains, so a program with its own `SIGINT` handler still gets the cleanup, still decides what happens next, and gets exactly **one** delivery for one Ctrl-C. A runtime that refuses to raise a given signal at itself — SIGHUP is `ENOSYS` on Windows — falls back to the POSIX code, because a shutdown that will not go is the one failure this package is named for. `ProcessLike` now requires `kill(pid, signal)` and `pid`. This is a breaking change to that type for anyone calling `install({ process })` with a hand-written double, and it is deliberate: optional members would have let a fake quietly take the exit path, which is how the missing re-raise survived `exit-hook`'s 21-case suite and `restore-cursor`'s 6-case suite intact. Both suites still pass 21 / 21 and 6 / 6. `closeout/exit-hook` is unchanged and stays faithful to its incumbent: it exits `128 + n` and listens on SIGINT and SIGTERM only — no SIGHUP — because `exit-hook@5.1.0` registers exactly `beforeExit`, `SIGINT`, `SIGTERM`, `exit` and `message`, and its own suite grades the exit codes. Use closeout's `onExit` rather than the drop-in when a closing terminal has to reach your cleanup; closeout's own wiring has covered SIGHUP all along. ### Patch Changes - [#339](https://github.com/ofri-peretz/burgee/pull/339) [`f295630`](https://github.com/ofri-peretz/burgee/commit/f2956301d5f9dcbcac0b001b00ebaf0315891fac) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` constrains token names, because it was promising something no host honours. `tokens` was described as any name to a `#rrggbb` colour. `roundel`'s `validate()` accepts ten semantic names — `error`, `warn`, `ok`, `hint`, `muted`, `command`, `flag`, `value`, `heading`, `ground` — and throws on everything else. So a plugin author doing exactly what their own `E_PLUGIN_SCHEMA` error tells them, comparing their object against `roundel/schema.json`, got a green from the schema and `"accent" is not a token` from `register()`. Measured 2026-09-16 with `{ accent: '#336699' }`. The schema now carries `propertyNames.enum`, and `scripts/plugin-contract-lock.test.ts` pins the enum and the runtime set to each other from both sides, so neither can grow a name the other does not know. Every host ships a byte-identical copy of this file (`plugin-schema-lock.test.ts` asserts it), which is why nine packages are listed. Only the key `roundel` owns is constrained: describing `widgets`, `handlers`, `sources`, `resolvers` or `commands` in a file all eight hosts share is what made _flagstaff_ start validating caique's key last time (`PluginError: plugin.widgets.later: expected object, got boolean`), and those stay in `plugin-schema-lock`'s `UNDESCRIBED` list with that reason. `linegauge` is in the list for a different change: `ceilings.json`'s R9 block now records the bar as D1's tree-inclusive ceiling — 83,538 against 170,342, a ratio of 0.4904 — and keeps the superseded `get-east-asian-width` bar beside it with the count of entries that cleared it. ## 0.1.0 ### Minor Changes - [#227](https://github.com/ofri-peretz/burgee/pull/227) [`cf637a8`](https://github.com/ofri-peretz/burgee/commit/cf637a8221cb08c5a0e6b631dbe2005256e9dcf8) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - closeout runs your exit handlers exactly once, on every path The package existed as a reserved name exporting a string constant. It now does the job its description has always claimed. A program leaves by several doors — returning from `main`, `process.exit`, SIGINT, SIGTERM, SIGHUP — and a handler registered on `'exit'` alone catches one of them. That is why Ctrl-C so often leaves a hidden cursor, a half-written file, or a lock nobody released. - **`onExit(handler)`** — runs once, on every path, and returns the function that unregisters it. Two signals, or a signal and the `'exit'` behind it, are one shutdown. - **`hideCursor(stream)`** — hides the cursor and registers the restore **in the same call**, so the two cannot drift apart. Returns the show function, which also unregisters. Idempotent, and silent on a non-TTY, because escape sequences in a pipe corrupt the output the pipe carries. - **A deadline**, two seconds by default. A handler awaiting something that never resolves turns Ctrl-C into a process the user kills twice, and the second one is SIGKILL with no cleanup at all. Abandoning a slow handler is the better trade. - **`createRegistry`** and **`install({ process })`** — the shutdown logic with no process attached, and the wiring pointed at one that is not the global. Every case in the suite runs without a real signal. One handler's failure is its own: a throw is reported and the rest still run. Shutdown is the worst place for an exception to short-circuit a loop, because the handler that restores the terminal is usually registered last. Importing the package attaches nothing — the process-wide instance installs on first use. Still to come: raw mode and alternate-screen restore, and the graded drop-in paths for `signal-exit`, `exit-hook` and `restore-cursor`. ### Patch Changes - [#245](https://github.com/ofri-peretz/burgee/pull/245) [`9818135`](https://github.com/ofri-peretz/burgee/commit/9818135cb1a72ff3b7edcdb3b84177688927a497) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - A program that installed its own handler for a signal keeps it. `closeout` ran its cleanup and then exited unconditionally, which overrules a program that asked to own SIGINT — one that wants to finish a request and exit 7, or ignore Ctrl-C entirely. It now stands its own listener down, and leaves only when no other listener remains. Cleanup is unchanged: it still runs on every path, which is not what was being deferred. --- # Coming from exit-hook > An exit-hook alternative with a drop-in path: import exitHook from closeout/exit-hook, graded 21 / 21 by exit-hook's own test suite — then phases that hand the terminal back last, and an onExit that reaches every exit path under one bounded deadline. Source: https://closeout.interlace.tools/docs/coming-from/exit-hook **closeout** is an **exit-hook alternative** you adopt by changing one import. `closeout/exit-hook` is exit-hook 5's API, and exit-hook's own test suite is the grade. ## Migrate from exit-hook in one import ```diff - import exitHook, { asyncExitHook, gracefulExit } from 'exit-hook'; + import exitHook, { asyncExitHook, gracefulExit } from 'closeout/exit-hook'; ``` Everything else stays: `exitHook(fn)` and `asyncExitHook(fn, { wait })` return the function that unregisters the hook, `gracefulExit(code?)` exits after the asynchronous hooks have run, and the `Options` type is exported under exit-hook's name. So do its semantics: - **The bound is yours, per hook.** `wait` is required and positive, and the effective bound is the largest `wait` registered. closeout's own 2000 ms deadline is *not* imposed — a drop-in that silently tightens your timeout is not a drop-in. - **The signals are exit-hook's.** `SIGINT` and `SIGTERM`, exiting `128 + n` — 130 and 143 — plus `beforeExit`, `process.exit()` and PM2's `shutdown` message. There is no `SIGHUP` here, because there is none in exit-hook. - **A synchronous exit abandons async hooks** and prints exit-hook's `SYNCHRONOUS TERMINATION NOTICE`, word for word. ## Is closeout compatible with exit-hook? Graded, not claimed. exit-hook's own suite, vendored at 5.1.0 and unmodified apart from the import specifier, runs against `closeout/exit-hook` beside a control that runs it against real exit-hook: | | passing | rate | | :-- | --: | --: | | `closeout/exit-hook` | 21 / 21 | 100.0% | | exit-hook itself (control) | 21 / 21 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority, and its front-end column reads `closeout` because the row's import is composed onto the package root as `closeout/exit-hook`. Eighteen of the 21 cases spawn a real process and assert what it did: the exit code it left with, and the bytes that made it out — 20,000 lines of stdout under backpressure among them. Four of those cases kill their child after a fixed 1000 ms. On a heavily loaded machine the child can lose that race before its hooks are installed, against real exit-hook as much as against closeout, so a run that reads 17 / 21 is upstream's clock, not a regression. ## What you gain over exit-hook The façade is exit-hook's behaviour and nothing else, at exit-hook's weight. The gain is one import away, on closeout itself: - **The terminal comes back last.** closeout's `onExit` runs handlers in phases, so a program that also calls `hideCursor(stream)` gets its cursor back after every handler. The façade does not order its hooks against closeout's own. - **`onExit` when you want every door.** closeout's own API reaches `SIGHUP`, `SIGQUIT`, an uncaught throw and an unhandled rejection too, under one deadline for the whole shutdown that names the handlers that had not returned — and, when no other listener owns the signal, re-raises it so the process really dies of it rather than exiting `128 + n`. - **One record, three readers.** `onExit`'s handler receives the record that `reportToJson()` and `reportToEvent()` project, so a `--json` line and an agent event cannot disagree with what the handler was told. ```diff - import exitHook from 'closeout/exit-hook'; - exitHook(() => releaseTheLock()); + import { onExit } from 'closeout'; + onExit(() => releaseTheLock()); ``` It costs nothing in size. In a bundle `closeout/exit-hook` is 1,579 B against exit-hook 5.1.0's 1,645 B, measured the way the benchmarks page measures every drop-in. ## When to switch from exit-hook - A closing terminal (`SIGHUP`) or an uncaught error has to reach your cleanup. - You also restore a cursor, and the order your hooks run in has to be a decision. If one exit hook on `SIGINT` and `SIGTERM` is all you need, exit-hook is already a good package. The API, the phases and the deadline are on [closeout](/docs). --- # Coming from restore-cursor > A restore-cursor alternative with a drop-in path: import restoreCursor from closeout/restore-cursor, graded 6 / 6 by restore-cursor's own test suite — then zero dependencies, and a restore phase that runs after every other exit handler. Source: https://closeout.interlace.tools/docs/coming-from/restore-cursor **closeout** is a **restore-cursor alternative** you adopt by changing one import. `closeout/restore-cursor` is restore-cursor 5's API, and restore-cursor's own test suite is the grade. ## Migrate from restore-cursor in one import ```diff - import restoreCursor from 'restore-cursor'; + import restoreCursor from 'closeout/restore-cursor'; ``` Everything else stays: `restoreCursor()` arranges for the cursor to be shown again however the process ends, registers at most once, and writes nothing when neither stream is a terminal — escape sequences in a pipe corrupt the output the pipe exists to carry. The stream is chosen the way restore-cursor chooses it: stderr if it is a TTY, otherwise stdout if it is. The decision is taken when you call, and the sequence goes out at exit whatever `isTTY` says by then. ## Is closeout compatible with restore-cursor? Graded, not claimed. restore-cursor's own suite, vendored at 5.1.0 and unmodified apart from the import specifier, runs against `closeout/restore-cursor` beside a control that runs it against real restore-cursor: | | passing | rate | | :-- | --: | --: | | `closeout/restore-cursor` | 6 / 6 | 100.0% | | restore-cursor itself (control) | 6 / 6 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority, and its front-end column reads `closeout` because the row's import is composed onto the package root as `closeout/restore-cursor`. Four of the six cases spawn a child with stdout and stderr forced to each TTY combination and assert which stream the sequence came out on — or that nothing did; the other two write and remove the script those four run. ## What you gain over restore-cursor - **Zero dependencies.** restore-cursor 5.1.0 depends on `onetime` and `signal-exit`, and `onetime` on `mimic-function`. The once-only guard and the exit registration are closeout's own. - **Last is a phase, not a sort.** The restore is registered in closeout's `restore` phase, which runs after `flush` and `release` — closeout's answer to restore-cursor's `alwaysLast`, and ordered against every other handler, a plugin's included, by declaration rather than by import order. - **Every exit path, bounded.** The restore rides closeout's own `onExit`: a normal exit, an emptied event loop, `SIGINT`, `SIGTERM`, `SIGHUP`, `SIGQUIT`, an uncaught throw and an unhandled rejection, once, under a deadline that a hung handler cannot outlast. - **Hide and restore as one call.** If you pair restore-cursor with a hand-written hide, `hideCursor(stream)` hides and registers the restore at the same moment, so the two cannot drift apart: ```js import { hideCursor } from 'closeout'; const restore = hideCursor(process.stderr); try { await drawTheSpinner(); } finally { restore(); } ``` `hideCursor` is closeout's own API, not a cli-cursor drop-in. ## When to switch from restore-cursor - Ctrl-C leaves a hidden cursor in your users' shells. - Your cursor restore has to run after your other cleanup, and you want that to be a guarantee rather than an import order. The API, the phases and the deadline are on [closeout](/docs). --- # Coming from signal-exit > A signal-exit alternative with a drop-in path: import { onExit } from closeout/signal-exit, graded 134 / 135 by signal-exit's own test suite, level with signal-exit itself — then an onExit that runs once on every exit path under a bounded deadline, and reports as --json or an agent event. Source: https://closeout.interlace.tools/docs/coming-from/signal-exit **closeout** is a **signal-exit alternative** you adopt by changing one import. `closeout/signal-exit` is signal-exit 4's API, and signal-exit's own test suite is the grade. ## Migrate from signal-exit in one import ```diff - import { onExit } from 'signal-exit'; + import { onExit } from 'closeout/signal-exit'; ``` Everything else stays: `onExit(handler, { alwaysLast })` returns the function that removes the handler, the handler is called with `(code, signal)`, and returning `true` from it claims the signal so the process lives on. `load()`, `unload()` and `signals` are exported beside it, and `closeout/signal-exit/signals` is signal-exit's `signal-exit/signals`. So are its semantics: - **A fatal signal still kills.** When the only listeners for a signal are signal-exit's, the handlers run and the signal is re-raised, so a parent sees `signal: 'SIGHUP'`, not an exit code. - **Every exit path is seen**, `process.exit()` and `process.reallyExit()` included. - **One emitter per process**, under the key signal-exit itself uses, so a program with this façade and a transitive copy of real signal-exit runs every handler once and re-raises once. Like signal-exit, the façade ships twice: `require('closeout/signal-exit')` loads a CommonJS file and `import` an ES one over the same code and the same emitter. It is graded against signal-exit 4: version 3's callable default, `require('signal-exit')(handler)`, is not reproduced. The two neighbours in this layer have one-import paths too, each graded by its incumbent's own suite: ```diff - import exitHook, { asyncExitHook, gracefulExit } from 'exit-hook'; - import restoreCursor from 'restore-cursor'; + import exitHook, { asyncExitHook, gracefulExit } from 'closeout/exit-hook'; + import restoreCursor from 'closeout/restore-cursor'; ``` ## Is closeout compatible with signal-exit? Graded, not claimed. signal-exit's own suite, vendored at 4.1.0 and unmodified apart from the import specifier, runs against `closeout/signal-exit` beside a control that runs it against real signal-exit: | | passing | rate | | :-- | --: | --: | | `closeout/signal-exit` | 134 / 135 | 99.3% | | signal-exit itself (control) | 134 / 135 | 99.3% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority, and its front-end column reads `closeout` because the row's import is composed onto the package root as `closeout/signal-exit`. **The one case short fails against signal-exit itself.** It is `signal-exit-test.ts` > `does not exit if user handles signal`: its fixture re-sends `SIGTERM` from a `setTimeout` inside the listener and expects the fourth to kill the process, and on current Node the process exits cleanly after the first. That was measured on Node 22, 24 and 26 on macOS and on Node 24 in a Linux container, with the fixture requiring signal-exit directly; signal-exit's last release, 2023-07-29, predates all of them. The Compatibility page lists it as a declared control failure of exactly one. The suite registers 135 cases on Linux and 127 on macOS, because Linux adds four signals — `SIGIO`, `SIGPOLL`, `SIGPWR`, `SIGSTKFLT` — to two loops over the signal list. The reference is Linux's 135, and all eight extra cases pass against signal-exit and against closeout. ## What you gain over signal-exit `closeout/signal-exit` is deliberately signal-exit's behaviour, so the façade alone changes the package name and not what your process does. The gain is closeout's own `onExit`, which you adopt a handler at a time: ```diff - import { onExit } from 'closeout/signal-exit'; - const off = onExit((code, signal) => { + import { onExit } from 'closeout'; + const off = onExit(({ path, code, signal, error }) => { releaseTheLock(); }); ``` - **Exactly once, on every door.** A normal exit, an emptied event loop, Ctrl-C, `SIGTERM`, `SIGHUP`, `SIGQUIT`, an uncaught throw and an unhandled rejection all reach the same handler, once, even when two fire at the same moment. `path` says which door it was, and `error` is what was thrown or rejected on the two paths that have one. - **Shutdown cannot hang.** A bounded deadline — `DEFAULT_DEADLINE` is 2000 ms — ends a shutdown whose handlers do not return, and the result names the ones that had not. - **Phases, not registration order.** Handlers run in `flush`, `release`, `restore` order, so the cursor comes back after the file is flushed, whichever registered first. - **One record, three readers.** The record your handler receives is what `reportToJson()` and `reportToEvent()` project, so a `--json` line and an agent event cannot disagree with what the handler was told. SIGKILL is not deliverable to a listener, by design, and neither package claims it. ## When to switch from signal-exit - You want signal-exit's behaviour, and exit-hook's and restore-cursor's, from one package. - A shutdown handler that never returns has hung your CLI or your CI job. - An uncaught throw or an unhandled rejection has to reach your cleanup. The API, the phases and the deadline are on [closeout](/docs). --- # Compatibility > How closeout's three drop-ins are graded — each incumbent's own test suite, unedited — the current grades, including signal-exit's 134 / 135 level with signal-exit itself, and the differences that remain. Source: https://closeout.interlace.tools/docs/drop-ins `closeout/signal-exit`, `closeout/exit-hook` and `closeout/restore-cursor` are graded, not described as compatible. Each is run against its incumbent's **own test suite**, by [compat-oracle](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/README.md), in CI. ✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades. ### Compatibility | Capability | **closeout** | signal-exit | exit-hook | restore-cursor | cli-cursor | | :-- | :-- | :-- | :-- | :-- | :-- | | **Passes exit-hook's own test suite** — `closeout/exit-hook` is graded by exit-hook 5.1.0's own tests, unedited, so changing the import keeps exit-hook's behaviour, 128 + n exit codes included. | [✓ 21 / 21 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/exit-hook.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cli-cursor/test.js) | | **Passes restore-cursor's own test suite** — `closeout/restore-cursor` is graded by restore-cursor 5.1.0's own tests, which spawn a child per case and check which stream the cursor comes back on. | [✓ 6 / 6 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/restore-cursor.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cli-cursor/test.js) | | **Passes cli-cursor's own test suite** — `closeout/cli-cursor` runs cli-cursor 5.0.0's own suite, unmodified: 8 of 8, the same as cli-cursor itself, including the case that spawns the module and expects it to print nothing. | [✓ 8 / 8 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/cli-cursor.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cli-cursor/test.js) | | **Passes signal-exit's own test suite, level with signal-exit** — `closeout/signal-exit` passes 134 of the 135 cases of signal-exit 4.1.0's own suite, and signal-exit itself passes the same 134: the one case short fails for both on current Node. | [◐ 134 / 135 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/signal-exit.json) | [◐ its own suite, the control run, passes 134 of 135: this case fails on Node 22, 24 and 26, all released after signal-exit's last release](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cli-cursor/test.js) | The counts are compat-oracle's baselines, the pass count each drop-in is held to. The family's [compatibility page](https://burgee.interlace.tools/docs/compatibility) is generated from the oracle's last run and is the authority for the current figures, beside every other drop-in in the family. ## How a suite is graded 1. The incumbent's repository is cloned at the release tag of the graded version — signal-exit 4.1.0, exit-hook 5.1.0, restore-cursor 5.1.0 — and its test directory copied into `packages/compat-oracle/vendor/`. No incumbent ships its tests to npm, so a tarball could not be used. Each copy's `PROVENANCE` file names the tag, the commit and the command that reproduces it. 2. The only edit is the import that reaches the library: it is rewritten to a shim generated per run. Assertions, fixtures and helpers are upstream's, byte for byte. 3. A **control run** points the shim at the real incumbent first. That proves the harness before it grades anything of ours, and the control's total is what every rate is measured against. 4. The **target run** points the same shim at closeout's drop-in. ## What each grade covers - **exit-hook — 21 of 21.** Eighteen cases spawn a real process and assert what it did: the code it left with and the bytes that made it out, 20,000 lines of stdout under backpressure among them. Four kill their child after a fixed 1000 ms, and on a heavily loaded machine the child can lose that race against real exit-hook as much as against closeout. - **restore-cursor — 6 of 6.** Four cases spawn a child with stdout and stderr forced to each TTY combination and assert which stream the sequence came out on, or that nothing did. - **signal-exit — 134 of 135, level with signal-exit.** The control run, signal-exit 4.1.0 against its own suite, also passes 134. The case both fail is `signal-exit-test.ts` > `does not exit if user handles signal`: its fixture re-sends SIGTERM from a timer inside the listener and expects the fourth to kill the process, and on current Node the process exits cleanly after the first. That was measured on Node 22, 24 and 26 with the fixture requiring signal-exit directly; signal-exit's last release, 2023-07-29, predates all three. The suite registers 135 cases on Linux and 127 on macOS, because Linux has four more signals; all eight extra cases pass for both. ## Known differences These are deliberate, and each is written down where the code is: - **`closeout/exit-hook` keeps exit-hook's signals and codes.** It listens on SIGINT and SIGTERM and not SIGHUP, and exits `128 + n` rather than dying of the signal, because exit-hook does both and its suite grades them. `signal.test.ts` pins both so nobody closes the gap by accident. closeout's own `onExit` is where SIGHUP and the re-raise live. - **`closeout/exit-hook` keeps exit-hook's bound.** The per-hook `wait` is honoured and closeout's 2000 ms deadline is not imposed. It runs exit-hook's own machine, not closeout's phases, so it bundles at exit-hook's weight; closeout's `onExit` is where the phases, the deadline report and a cursor restored after every handler live. - **`closeout/signal-exit` has two builds, as signal-exit does.** `require()` reaches a CommonJS file, because signal-exit's suite swaps the global `process` and re-requires the module, and an ES module is evaluated once per process however the cache is edited. `import` reaches an ES file over the same code, so a bundle carries no CommonJS interop. Both share one emitter. It is graded against signal-exit 4: version 3's callable default, `require('signal-exit')(handler)`, is not reproduced. - **`closeout/signal-exit` shares signal-exit's emitter**, under the global key signal-exit itself uses, so a program with the drop-in and a transitive copy of real signal-exit runs each handler once. - **`closeout/restore-cursor` has no dependencies.** restore-cursor depends on `onetime` and signal-exit; the drop-in registers its restore in closeout's `restore` phase instead of signal-exit's `alwaysLast`. ## Moving one import ```diff - import { onExit } from 'signal-exit'; + import { onExit } from 'closeout/signal-exit'; ``` `npx burgee migrate --dry-run` lists every import it would rewrite — only drop-ins graded level with their incumbent — and `npx burgee migrate` makes the change ([Migrate](https://burgee.interlace.tools/docs/migrate)). An `overrides` entry is not the route: it would point the incumbent's name at closeout's root, which is closeout's own API. [Incremental migration](/docs/recipes/incremental-migration) moves from a drop-in to `onExit` a handler at a time. --- # FAQ > Short answers about closeout — SIGKILL, process.exit, async handlers, the deadline, exit codes, owning a signal, Windows, CommonJS and the drop-ins — each with where to read more. Source: https://closeout.interlace.tools/docs/faq ## Does it run on SIGKILL? No, and nothing can: the operating system never delivers SIGKILL to a listener. closeout listens for `SIGINT`, `SIGTERM`, `SIGHUP`, `SIGQUIT` and, on Windows, `SIGBREAK`. The [deadline](/docs/guides/deadline) exists so that a user or an orchestrator never *needs* to send SIGKILL to get past a hung cleanup. ## My async handler did not finish after `process.exit()`. `process.exit()` gives no time to await anything: Node is already leaving. On that path closeout calls every handler synchronously and lists an async one as unfinished in the report. Make work that must happen there synchronous, or leave by returning from `main`, by a signal or by a throw, where handlers are awaited. [Every exit path](/docs/guides/exit-paths#synchronous-and-asynchronous-handlers). ## Can a handler change the exit code? On a signal, a throw or a rejection, no: how the process leaves is decided before any handler runs. On `process.exit()` and a normal finish, Node reads `process.exitCode` after the handlers, so a handler that assigns it does change the code — read the code from `report.code` instead of setting it. [Signals and exit status](/docs/guides/signals#the-exit-code). ## Why does my process die of SIGINT instead of exiting 130? Because it was interrupted, and that is what a parent needs to know: a shell prints `^C`, `make` stops, a CI runner marks the job cancelled. closeout re-raises the signal after the handlers run. `closeout/exit-hook` exits `130` instead, because exit-hook does. [Signals and exit status](/docs/guides/signals). ## My program has its own SIGINT handler. Will closeout exit under it? No. When the program has its own listener, closeout runs the handlers and stands aside; the program decides what happens next, with one delivery for one Ctrl-C. ## What is the default deadline, and can I turn it off? 2000 ms, provisional. `install({ deadline })` sets another; `Infinity` and `0` are refused with a `USAGE` error, because each brings back the hang or the abandoned cleanup the deadline exists to prevent. [The deadline](/docs/guides/deadline). ## In what order do handlers run? By phase: `flush`, then `release` (the default), then `restore`, each awaited before the next; within a phase, in registration order. [Phases](/docs/guides/phases). ## Does it work on Windows? The registry, the phases and the deadline do. SIGHUP cannot be raised at a process on Windows, so after a SIGHUP there closeout exits `129` instead of re-raising. closeout's real-process signal tests are POSIX-only and skipped on Windows. ## Can I use it from CommonJS? Yes, on Node 22.13+: the package is ESM with a `default` condition, so `require('closeout')` loads it through `require(esm)`. `require('closeout/signal-exit')` loads a CommonJS file, as signal-exit's does, and `import` an ES one over the same code. ## Does importing it attach listeners? No. The process-wide instance installs on the first `onExit`, `hideCursor`, `rawMode` or `alternateScreen` call. A library that imports closeout for its types pays nothing. ## Is `closeout/signal-exit` exactly signal-exit? It passes 134 of the 135 cases of signal-exit 4.1.0's own suite, and signal-exit itself passes the same 134; the case both fail is signal-exit's, on current Node. The differences that remain are on [Compatibility](/docs/drop-ins#known-differences). ## Is `closeout/exit-hook` heavier than exit-hook? No. In a bundle it is 1,579 B against exit-hook 5.1.0's 1,645 B, measured the way the benchmarks page measures every drop-in. It runs exit-hook's own machine; the phase ordering and the bounded runner stay on closeout's own `onExit`. --- # Getting started > Install closeout, register one exit handler, and watch it run once on every way a Node.js program can end: exit, an emptied loop, a signal, a throw and a rejection. Source: https://closeout.interlace.tools/docs/getting-started closeout is one registry for everything a program has to do on the way out. You register a handler with `onExit`, and it runs **exactly once**, whichever way the program ends, with a record of how it ended. ## Install ```bash npm install closeout ``` It has no dependencies: Node builtins only. It is ESM with a `default` condition, so `require('closeout')` also works from CommonJS on Node 22.13+, and `closeout/signal-exit` is CommonJS itself, as signal-exit is. Importing the package attaches nothing to the process. The process-wide instance installs on the first `onExit`, so a library that imports closeout only for its types pays nothing. ## A first handler ```js title="doors.mjs" import { onExit } from 'closeout'; onExit(({ path, code, signal, error }) => { console.log(`leaving by ${path}: code ${code}, signal ${signal}, error ${error}`); }); const door = process.argv[2]; if (door === 'exit') process.exit(3); if (door === 'throw') throw new Error('mid-render'); if (door === 'reject') Promise.reject(new Error('never awaited')); if (door === 'signal') { setTimeout(() => {}, 10_000); process.kill(process.pid, 'SIGTERM'); } console.log('work done'); ``` Let the program finish, and the handler runs when the event loop empties (`beforeExit`), where an async handler still has time to finish: ```text title="node doors.mjs" work done leaving by beforeExit: code 0, signal null, error null ``` Call `process.exit(3)`, and the handler runs on `exit` with the code you chose, in `report.code`. (Node reads `process.exitCode` after the `exit` listeners, so a handler that assigns it would change the code — [leave it alone](/docs/guides/signals#the-exit-code).) ```text title="node doors.mjs exit" exit="3" leaving by exit: code 3, signal null, error null ``` Send a signal — it arrives once the synchronous work is done — and the handler runs before the process dies **of that signal**. A parent sees `SIGTERM`, not an exit code, which is how a shell, `make` or a CI runner tells a cancelled job from a failed one: ```text title="node doors.mjs signal" signal="SIGTERM" work done leaving by signal: code null, signal SIGTERM, error null ``` Throw, and the handler is told what was thrown; then the error is printed and the process exits 1, as Node would have done. (The stack trace is turned off here so this page can be checked byte for byte.) ```text title="node --stack-trace-limit=0 doors.mjs throw" exit="1" leaving by uncaught: code 1, signal null, error Error: mid-render [Error: mid-render] ``` A promise nobody awaited is the fifth way out: ```text title="node --stack-trace-limit=0 doors.mjs reject" exit="1" work done leaving by rejection: code 1, signal null, error Error: never awaited [Error: never awaited] ``` Every output block on this site is checked: `tests/examples.test.ts` writes each titled file, runs the command in the block's title, and compares both what it printed and how it ended. ## The record Every handler is handed one record: | field | what it is | | :-- | :-- | | `path` | `'exit'`, `'beforeExit'`, `'signal'`, `'uncaught'` or `'rejection'` | | `signal` | the signal that ended the program, or `null` | | `code` | the exit code it is leaving with, or `null` for a signal | | `error` | what was thrown or rejected, on those two paths; `null` otherwise | The same record is what `reportToJson()` and `reportToEvent()` project, so a `--json` line and an agent event cannot disagree with what the handler was told ([Reports for logs and agents](/docs/guides/reports)). `onExit` returns a function that takes the handler back out. A program that cleans up normally can remove its handler and leave nothing for exit to do. **SIGKILL cannot be handled**, by closeout or by anything else: the operating system does not deliver it to a listener. The signals closeout listens for are `SIGINT`, `SIGTERM`, `SIGHUP`, `SIGQUIT` and, on Windows, `SIGBREAK`. ## Where next - [Guides](/docs/guides/exit-paths): exit paths, phases, the deadline, the terminal, signals and exit status, reports, plugins. - [Why closeout](/docs/why-closeout): what it does that signal-exit, exit-hook and restore-cursor do not, cell by cell, with the evidence. - [Coming from signal-exit](/docs/coming-from/signal-exit) and the other two: change one import. - [API reference](/docs/api): every export of every entry point. --- # The deadline > A bounded shutdown: closeout stops waiting for exit handlers after a deadline (2000 ms by default), leaves with the code it was leaving with, and names the handler that hung. Source: https://closeout.interlace.tools/docs/guides/deadline A handler that awaits something that never resolves — a socket that will not close, a lock nobody releases — turns Ctrl-C into a process the user has to kill twice. The second time is SIGKILL, which runs no handlers at all. closeout bounds the shutdown instead: after the deadline it stops waiting, and says which handler it stopped waiting for. ```js title="hang.mjs" import { install } from 'closeout'; const { onExit } = install({ deadline: 200 }); onExit(() => console.log('flushed the log'), 'flush'); onExit(() => new Promise(() => {}), { label: 'close-the-socket' }); onExit(() => console.log('terminal handed back'), 'restore'); setTimeout(() => {}, 10_000); process.kill(process.pid, 'SIGTERM'); ``` ```text title="node hang.mjs" signal="SIGTERM" flushed the log terminal handed back closeout: shutdown deadline of 200ms expired; exiting anyway. Handlers that had not returned: close-the-socket ``` Three things happened there: 1. **The hang was named.** The line on stderr says which handler had not returned, by its `label` — or by the function's own name, or `(anonymous)`. An anonymous arrow is exactly the shape that hangs, so give the ones that can hang a label. 2. **The restore still ran.** The deadline stops waiting for the phase that hung; it does not skip the phases after it. 3. **The process left the way it was leaving.** It still died of `SIGTERM`. A breach never invents an exit code: a program that called `process.exit(3)` leaves with 3. ## Setting it ```js import { install } from 'closeout'; const closeout = install({ deadline: 5000 }); closeout.onExit(releaseTheLock); ``` `install()` wires a registry of its own to the process. The process-wide `onExit` uses the default, `DEFAULT_DEADLINE`, which is **2000 ms**. That figure is provisional: measured on five cleanup shapes, four finished inside 70 ms and the fifth (removing a directory of 100 files) depended entirely on the disk it was queued behind. closeout's README has the measurement. `Infinity` and `0` are refused where they are written, with a `DeadlineError` whose `code` is `USAGE`: ```js title="refused.mjs" import { install } from 'closeout'; try { install({ deadline: Infinity }); } catch (error) { console.log(error.code); } ``` ```text title="node refused.mjs" USAGE ``` Both reintroduce the failure the deadline removes: one waits forever, and the other gives no asynchronous handler a turn at all. ## Reporting the breach yourself `onTimeout` replaces the stderr line. It is handed the whole report, so a program with a `--json` mode can print the breach as JSON instead ([Reports](/docs/guides/reports)): ```js install({ deadline: 2000, onTimeout: (report) => logger.warn({ unfinished: report.unfinished }) }); ``` ## When the deadline does not apply - **Nothing hangs.** When every handler settles, shutdown returns at once. A clean shutdown never pays for the bound, and one whose handlers are all synchronous returns without waiting on the clock at all. - **`exit`.** On `process.exit()` Node gives no time at all, so there is nothing to bound: async handlers are abandoned and listed as unfinished. - **`closeout/exit-hook`.** The drop-in keeps exit-hook's own bound, the per-hook `wait`, because a drop-in that silently tightened your timeout would not be a drop-in. ## What is tested - [`matrix.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/matrix.test.ts): a handler that never returns does not stop a signal, a throw, a rejection or `beforeExit` from ending the process, and is named in the report; a breached deadline exits with the signal's code, not one a handler set on its way past. - [`deadline.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/deadline.test.ts): `Infinity` and `0` are refused; restore runs after a hang in `flush`. - [`signal.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/signal.test.ts): a hang that holds nothing in the event loop does not turn a signal into exit 0. --- # Every exit path, exactly once > The five ways a Node.js process ends, which ones can await, and how closeout runs each handler once when two fire together — and keeps going when one throws. Source: https://closeout.interlace.tools/docs/guides/exit-paths A program leaves by one of five doors, and closeout listens on all of them: | path | what triggers it | can a handler await? | | :-- | :-- | :-- | | `beforeExit` | the event loop emptied: the program simply finished | yes | | `signal` | `SIGINT` (Ctrl-C), `SIGTERM`, `SIGHUP`, `SIGQUIT`, `SIGBREAK` | yes, up to the deadline | | `uncaught` | an exception nobody caught | yes, up to the deadline | | `rejection` | a rejected promise nobody awaited | yes, up to the deadline | | `exit` | `process.exit()`, or the end after any of the above | no — Node is already leaving | A handler registered on `process.on('exit')` alone sees only the last row, and cannot await anything there. That is why a Ctrl-C so often leaves a lock file behind: the one event the handler listened for was never the one that fired. ## Exactly once Two doors often open together. Ctrl-C twice, or a signal and the `exit` that follows it, or a throw inside a shutdown that was already running. closeout treats every arrival after the first as the same shutdown: each handler runs once, and a later trigger waits for the first run instead of starting a second one. ```js title="once.mjs" import { onExit } from 'closeout'; let runs = 0; onExit(() => { runs += 1; console.log(`cleanup run ${runs}`); }); setTimeout(() => {}, 10_000); process.kill(process.pid, 'SIGTERM'); process.kill(process.pid, 'SIGTERM'); ``` ```text title="node once.mjs" signal="SIGTERM" cleanup run 1 ``` A handler that ran twice would release a lock that another process has taken since. ## One handler's failure is its own A handler that throws is reported on stderr, and every handler after it still runs. Shutdown is the worst place for an exception to end a loop early, because the handler that restores the terminal is often the last one registered. ```js title="throws.mjs" import { install } from 'closeout'; const { onExit } = install({ onError: (error) => console.log(`reported: ${error.message}`) }); onExit(() => { throw new Error('the lock file was already gone'); }); onExit(() => console.log('the next handler still ran')); ``` ```text title="node throws.mjs" reported: the lock file was already gone the next handler still ran ``` `onError` defaults to printing the error on stderr. A program that owns a logger passes its own, as above. ## Synchronous and asynchronous handlers A handler may return a promise, and on every path but `exit` it is awaited — within the [deadline](/docs/guides/deadline). On `exit` there is no time left: closeout calls every handler synchronously, abandons any promise one returns, and names it as unfinished in the [report](/docs/guides/reports). So work that has to happen on `process.exit()` should be synchronous (`writeFileSync`, not `writeFile`), and work that needs to await belongs in a program that leaves by returning, by a signal or by a throw. ## Handlers the program owns closeout never takes a decision away from a program that made one. If the program has its own listener for a signal, or for `uncaughtException`, closeout runs the handlers and then stands aside: the program's listener decides whether and how to exit. See [Signals and exit status](/docs/guides/signals). ## What is tested - [`registry.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/registry.test.ts): a handler runs once however many times shutdown is asked for; the rest run after one throws. - [`restore.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/restore.test.ts): every door — five signals, `exit`, `beforeExit`, a throw and a rejection — runs the handlers. - [`matrix.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/matrix.test.ts): a handler that never returns does not stop any of those doors from ending the process. --- # Phases > flush, release, restore: closeout runs exit handlers in declared phases, each awaited before the next, so the terminal is handed back last whatever order things registered in. Source: https://closeout.interlace.tools/docs/guides/phases Registration order is the wrong order for a shutdown. The handler that shows the cursor again is registered by whichever renderer hid it, at the moment it first drew — usually early — so anything registered afterwards runs after the terminal has been handed back. closeout orders a shutdown by **phase** instead: | phase | what goes in it | | :-- | :-- | | `flush` | get the data out: write the file, drain the log, post the last event | | `release` | let go: locks, sockets, child processes, temp directories — **the default** | | `restore` | hand the terminal back: raw mode off, alternate screen left, cursor shown | Phases run in that order, and each is **awaited** before the next begins: an async handler in `flush` has settled before the first `release` handler starts. Handlers inside one phase start together, in registration order. ```js title="phases.mjs" import { onExit } from 'closeout'; onExit(() => console.log('restore: the terminal is handed back'), 'restore'); onExit(() => console.log('release: the lock file is removed')); onExit(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); console.log('flush: the log is written'); }, 'flush'); console.log('working'); ``` Registered backwards, run in order — and the `flush` handler's 50 ms wait is finished before `release` starts: ```text title="node phases.mjs" working flush: the log is written release: the lock file is removed restore: the terminal is handed back ``` ## Choosing a phase The second argument to `onExit` is the phase, or an options object when the handler also wants a name for the [deadline's report](/docs/guides/deadline): ```js onExit(flushTheLog, 'flush'); onExit(releaseTheLock); // 'release' onExit(async () => closeTheSocket(), { phase: 'release', label: 'close-the-socket' }); ``` `restore` is where closeout's own terminal undos go — `hideCursor`, `rawMode` and `alternateScreen` register there ([The terminal](/docs/guides/terminal)). Your own handler may use it too, when it is restoring terminal state closeout does not know about. A [plugin](/docs/guides/plugins) may not: a plugin handler in `restore` is refused, because "restore runs last" has to be enforced somewhere. ## Past the deadline When the deadline fires, closeout stops **waiting** for the phase that hung, and still **runs** the phases after it. A handler hung in `flush` does not get to decide that the cursor stays hidden. [The deadline](/docs/guides/deadline) shows it. ## On `exit` `process.exit()` gives no time to await anything, so on the `exit` path the phases run synchronously, still in order: every `flush` handler is called, then every `release`, then every `restore`. A promise returned there is abandoned and reported as unfinished. ## What is tested - [`plugin.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/plugin.test.ts): a handler runs before the restore though the restore was registered first; an async handler is awaited before the restore, not merely started first; the order holds on the synchronous path too. - [`deadline.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/deadline.test.ts): restore still runs after a handler hung in `flush`. --- # Plugins > closeout's plugin host: a plugin contributes named shutdown handlers to flush or release, is validated against the family schema, reads as data before it runs, and is checked by npx closeout check. Source: https://closeout.interlace.tools/docs/guides/plugins A plugin is one plain object, shared by the whole burgee family. closeout reads its `handlers` key and ignores every other layer's keys without complaining, so the same object works on whichever packages of the family a program has installed. ```js title="plugin.mjs" import { install } from 'closeout'; import { attach, contributions, register } from 'closeout/plugin'; register({ name: 'acme', handlers: [ { name: 'unlock', run: () => console.log('lock released') }, { name: 'flush-audit-log', phase: 'flush', run: () => console.log('audit log flushed') }, ], }); for (const c of contributions()) console.log(`${c.phase}: ${c.id}`); attach(install().registry); ``` `contributions()` lists the whole contributed shutdown, in the order it will run, without running any of it. `attach(registry)` wires each handler into its phase, and returns the function that takes them back off: ```text title="node plugin.mjs" flush: acme:flush-audit-log release: acme:unlock audit log flushed lock released ``` A plugin handler is named `":"`, which is what the [deadline's report](/docs/guides/deadline) prints when it hangs. ## What a plugin may declare | key | rule | | :-- | :-- | | `name` | required: the plugin's name | | `contract` | optional: the plugin contract it was written for; a newer one than this closeout knows is refused | | `handlers[].name` | required, because a hang has to be named | | `handlers[].run` | required: the handler, handed the same record `onExit` handlers get | | `handlers[].phase` | `flush` or `release` (the default) — **not** `restore` | `restore` is closeout's own last phase. A plugin handler admitted to it could run after the terminal was handed back depending on nothing but which registered first, which is the coincidence phases exist to replace. A refusal throws with a code — `E_PLUGIN_SCHEMA` or `E_PLUGIN_CONTRACT` — and a message that says what to use instead, and the refused plugin is not kept. `closeout check` adds a third, `E_NO_CONTRIBUTION`, for a plugin that registers but contributes no handler. ## Checking a plugin before it ships `npx closeout check ` loads a plugin module's default export, validates it and reports what closeout would do with it. It exits 0 when the plugin contributes, 1 on a refusal with a code and a fix, and 2 on a usage error: ```js title="unlock.mjs" export default { name: 'acme', handlers: [{ name: 'unlock', phase: 'restore', run: () => {} }], }; ``` ```text title="npx closeout check unlock.mjs" exit="1" E_PLUGIN_SCHEMA: plugin "acme": handlers[0]: "restore" is not a phase a plugin may use fix: use one of flush, release — "restore" is closeout’s own last phase, and a handler placed after it could not clean up what it was registered to clean up ``` ## What is tested - [`plugin.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/plugin.test.ts): a plugin's handler runs before the restore and is awaited first; the restore survives a plugin handler that never returns; the contributions read as data; another layer's object is kept; a `restore` handler, a handler with no `run` or no name, and a newer contract are each refused. --- # Reports for logs and agents > The shutdown as data: one record per shutdown, projected as a --json line by reportToJson and as an agent event by reportToEvent, with timedOut and the handlers that hung. Source: https://closeout.interlace.tools/docs/guides/reports A shutdown produces one record. Handlers receive its first four fields while it runs; when it is over, the registry's `report` — and the value `registry.run()` resolves with — adds what the deadline found: | field | what it is | | :-- | :-- | | `path` | `'exit'`, `'beforeExit'`, `'signal'`, `'uncaught'` or `'rejection'` | | `signal` | the signal, or `null` | | `code` | the exit code, or `null` for a signal | | `error` | what was thrown or rejected, or `null` | | `timedOut` | whether the deadline fired before the handlers were done | | `unfinished` | the handlers that had not returned, by label or name | `unfinished` is non-empty in two cases: the deadline fired, or the path was `exit`, where Node gives no time at all and an async handler never had a turn. ## Two projections of the one record `reportToJson(report)` is one line of JSON for a program's `--json` mode, with `error` flattened to text — `JSON.stringify` of an `Error` is `{}`, which looks like a report and carries nothing. `reportToEvent(report)` is the same values under the family's event key, `type: 'closeout.shutdown'`, for an agent's event stream. Both come from the record the handlers were given, so a log line and an agent event cannot disagree with it. Hand either to `onTimeout`, and a breached deadline is reported as data instead of a sentence: ```js title="breach.mjs" import { install, reportToJson } from 'closeout'; const { onExit } = install({ deadline: 100, onTimeout: (report) => console.error(reportToJson(report)), }); onExit(() => new Promise(() => {}), { phase: 'flush', label: 'upload-the-trace' }); setTimeout(() => {}, 10_000); process.kill(process.pid, 'SIGTERM'); ``` ```text title="node breach.mjs" signal="SIGTERM" {"path":"signal","signal":"SIGTERM","code":null,"error":null,"timedOut":true,"unfinished":["upload-the-trace"]} ``` ## Reading the report after a clean shutdown A registry you drive yourself resolves `run()` with the report, and keeps it on `registry.report` for a later caller — including after `runSync`, which cannot return a promise: ```js title="report.mjs" import { createRegistry, reportToEvent } from 'closeout'; const registry = createRegistry({ onError: (error) => console.log(`a handler failed: ${error.message}`) }); registry.add(() => { throw new Error('disk full'); }, 'flush'); const report = await registry.run({ code: 1, signal: null, path: 'uncaught', error: new Error('mid-render') }); const event = reportToEvent(report); console.log(event.type, event.path, event.code, event.timedOut, event.error.split('\n')[0]); ``` ```text title="node report.mjs" a handler failed: disk full closeout.shutdown uncaught 1 false Error: mid-render ``` A handler's own failure is not in the report. It goes to `onError` — stderr unless you pass one — because the report says how the program left, and a failed handler is a separate fact about one handler. ## What is tested - [`report.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/report.test.ts): the path is inferred only when unstated; an `Error` and a non-`Error` rejection both become text; the JSON is one line naming every handler that hung; the event is the same values under the family's key. --- # Signals and exit status > How closeout leaves after a signal: it re-raises it so the process dies of SIGINT or SIGTERM rather than exiting 128 + n, keeps a crash's exit code, and stands aside when the program owns the signal. Source: https://closeout.interlace.tools/docs/guides/signals A process that was interrupted and a process that exited 130 are two different events to everything above it. `WIFSIGNALED` is true for one and false for the other: a shell prints `^C` and stops a script, `make` stops a parallel build, a CI runner marks the job cancelled rather than failed, and a supervisor decides whether to restart. `128 + n` is the number a shell *reports* afterwards; it is not a status a process can give itself. ## Dies of the signal Once the handlers have run, closeout removes its own listener and raises the signal again at its own process, which then dies of it: ```js title="interrupted.mjs" import { onExit } from 'closeout'; onExit(({ signal }) => console.log(`cleaned up after ${signal}`)); setTimeout(() => {}, 10_000); process.kill(process.pid, 'SIGINT'); ``` ```text title="node interrupted.mjs" signal="SIGINT" cleaned up after SIGINT ``` On a runtime that refuses to raise a given signal at itself — SIGHUP on Windows — closeout falls back to the `128 + n` code rather than staying alive. ## The exit code On a signal, a throw or a rejection, how the process leaves is decided when shutdown starts, before any handler runs. A handler that tidies `process.exitCode` on its way past cannot turn a crash into a success, and a breached [deadline](/docs/guides/deadline) leaves the same way: ```js title="crash.mjs" import { onExit } from 'closeout'; onExit(() => { process.exitCode = 0; }); setTimeout(() => { throw new Error('mid-render'); }, 10); ``` ```text title="node --stack-trace-limit=0 crash.mjs" exit="1" [Error: mid-render] ``` On `process.exit()` and on a program that simply finished, the process is leaving on its own, and **Node** reads `process.exitCode` after the `exit` listeners have run. Every handler is told the code in `report.code`, but one that assigns `process.exitCode` there changes what the process exits with: ```js title="code.mjs" import { onExit } from 'closeout'; onExit(() => { process.exitCode = 0; }); process.exit(3); ``` ```text title="node code.mjs" ``` So read the code from the report, and leave `process.exitCode` alone in a handler. ## A program that owns the signal keeps it If the program has its own listener for the signal, it asked to decide what a Ctrl-C means: finish the request and exit 7, or ignore it. closeout runs the handlers and then stands aside, with one delivery for one signal: ```js title="owned.mjs" import { onExit } from 'closeout'; onExit(() => console.log('cleanup ran')); process.on('SIGTERM', () => { console.log('the program decides: finish the request, then exit 7'); setTimeout(() => process.exit(7), 20); }); setTimeout(() => {}, 10_000); process.kill(process.pid, 'SIGTERM'); ``` ```text title="node owned.mjs" exit="7" cleanup ran the program decides: finish the request, then exit 7 ``` The same holds for `uncaughtException` and `unhandledRejection`: with a listener of the program's own there, closeout runs the handlers and does not exit. ## The drop-ins keep their incumbents' behaviour - `closeout/signal-exit` re-raises, as signal-exit does. - `closeout/exit-hook` exits `128 + n` — 130 for SIGINT, 143 for SIGTERM — and listens on SIGINT and SIGTERM only, not SIGHUP, because exit-hook does both and its own suite grades them: ```js title="hook.mjs" import exitHook from 'closeout/exit-hook'; exitHook((code) => console.log(`exit-hook ran, leaving with ${code}`)); setTimeout(() => {}, 10_000); process.kill(process.pid, 'SIGTERM'); ``` ```text title="node hook.mjs" exit="143" exit-hook ran, leaving with 143 ``` Use closeout's own `onExit` rather than the drop-in when a closing terminal (SIGHUP) has to reach your cleanup, or when the parent should see the signal. ## What is tested - [`signal.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/signal.test.ts): SIGINT, SIGTERM, SIGHUP and SIGQUIT each kill a real process after the cursor comes back; a program with its own handler gets one delivery; `closeout/exit-hook` keeps exit-hook's signal list and codes. - [`install.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/install.test.ts): the re-raise, the stand-down, and the fallback code on a runtime that cannot raise. - [`matrix.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/matrix.test.ts): every handler is told the code the program is leaving with; a breached deadline exits with the signal's code, not one a handler set. --- # The terminal > hideCursor, rawMode and alternateScreen: change the terminal and register the undo in one call, so a program that dies by any door — even mid-shutdown — hands back a usable terminal. Source: https://closeout.interlace.tools/docs/guides/terminal A program that hides the cursor, switches the terminal to raw mode or enters the alternate screen has to put each back before it goes, on every way out. Forget one on Ctrl-C and the user's shell is left without a cursor, not echoing what they type, or showing a screen that is not their scrollback. closeout makes each change and registers its undo **in the same call**: ```js import { alternateScreen, hideCursor, rawMode } from 'closeout'; const undo = [rawMode(process.stdin), alternateScreen(process.stdout), hideCursor(process.stdout)]; try { await runTheFullScreenApp(); } finally { for (const back of undo) back(); } ``` | call | does now | undoes at exit, in `restore` | | :-- | :-- | :-- | | `hideCursor(stream)` | hides the cursor | shows it | | `rawMode(input)` | turns raw mode on | turns it off | | `alternateScreen(stream)` | enters the alternate screen | leaves it | | `showCursor(stream)` | shows the cursor now | — | Each returns the function that undoes it now and unregisters the exit undo, so a program that cleans up after itself leaves nothing for exit to do. Each undo runs **once**, whoever asks first — the program's `finally`, or the shutdown. ## Every door, last The undos go in the [`restore` phase](/docs/guides/phases), which runs after every `flush` and `release` handler. So the terminal comes back: - on every exit path: `exit`, `beforeExit`, every signal, a throw, a rejection; - after a handler that **threw**, because one handler's failure is its own; - after a handler that **hung**, because the deadline stops waiting but still runs `restore`; - after a **second signal**: a second Ctrl-C, or a SIGTERM behind it, while a handler still holds the first shutdown, waits for that shutdown instead of killing the process before the terminal is restored. `terminal-restore.test.ts` checks each of those on a real process: it spawns a child that enters raw mode and the alternate screen and hides the cursor, ends it by that path, and reads back the escape sequences and mode changes it actually made. ## Only what closeout turned on `rawMode(input)` turns off only what it turned on. If the input is already raw, somebody else owns that state: the call changes nothing, now or at exit. ## Nothing into a pipe A stream that is not a terminal gets no escape sequence, in either direction, and an input that is not a terminal gets no mode change. Escape sequences in a pipe corrupt the output the pipe exists to carry. ```js title="draw.mjs" import { alternateScreen, hideCursor, rawMode } from 'closeout'; rawMode(process.stdin); alternateScreen(process.stdout); hideCursor(process.stdout); console.log('drawing'); process.exit(0); ``` Piped, the only bytes out are the program's own: ```text title="node draw.mjs" drawing ``` ## Registering on your own instance The three calls register on the process-wide instance. `alternateScreen` and `rawMode` take the object `install()` returned as a second argument, to register on that one instead; the object's own `hideCursor` method does the same for the cursor: ```js import { alternateScreen, install } from 'closeout'; const closeout = install({ deadline: 5000 }); closeout.hideCursor(process.stdout); alternateScreen(process.stdout, closeout); ``` `closeout/cursor` is the same four functions without a registry: each takes, as its second argument, the function that registers its undo — `(undo) => unregister` — so a renderer with its own shutdown bookkeeping can use them without closeout's. It also exports the sequences: `HIDE_CURSOR`, `SHOW_CURSOR`, `ENTER_ALTERNATE_SCREEN` and `LEAVE_ALTERNATE_SCREEN`. ## What is tested - [`restore.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/restore.test.ts): every door leaves the terminal as it was found, last; a handler that threw or a deadline that fired does not keep it; nothing is undone twice; raw mode somebody else turned on stays on. - [`terminal-restore.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/terminal-restore.test.ts): the same on a real process, including the second signal. - [`cursor.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/cursor.test.ts): the pairing, the idempotence and the pipe. --- # closeout > Runs every exit handler exactly once on every path out of the process, restores the terminal, and holds shutdown to a deadline so it cannot hang. Zero dependencies. Drop-in paths for signal-exit, exit-hook, restore-cursor, cli-cursor and onetime. Source: https://closeout.interlace.tools/docs **closeout** runs every exit handler exactly once, on every path out of the process, restores the terminal, and holds shutdown to a deadline so it cannot hang. Every handler gets one record, and `reportToJson()` and `reportToEvent()` project that record as a `--json` line or an agent event. Zero dependencies. Node builtins only. Drop-in for **signal-exit**, **exit-hook** and **restore-cursor**: `npx burgee migrate` moves the imports to closeout's subpaths, each graded by the incumbent's own suite. **Over the platform.** The builtin is `process.on(signal)` beside `process.on('exit')`. An `exit` listener runs on a code and never on a signal, and a signal listener replaces the default exit rather than running before it. closeout runs each handler exactly once on every path, then leaves the way the process would have. The repository's absorption probe asks every supported Node line how many of those paths the builtin covers alone. **Close everything out.** To *close out* is to settle and finish — an account, a position, a shift. Everything outstanding is resolved and nothing is left open. That is what a process should do on the way out, and mostly does not. ## Install ```bash npm install closeout pnpm add closeout yarn add closeout bun add closeout ``` ## Quick start ```js import { onExit } from 'closeout'; const off = onExit(({ path, code, signal, error }) => { // Runs once, whichever door the program left by: a normal exit, an emptied event loop, // Ctrl-C, SIGTERM, SIGHUP, SIGQUIT, an uncaught throw, an unhandled rejection. releaseTheLock(); }); // Cleaned up early? Take the handler back out. off(); ``` Your handler is handed one record — `{ path, signal, code, error }` — and the same record is what `reportToJson()` and `reportToEvent()` project, so a `--json` line and an agent event cannot disagree with what the handler was told. `path` is `'exit' | 'beforeExit' | 'signal' | 'uncaught' | 'rejection'`. `error` is what was thrown or rejected on the two paths that have one, and `null` on the others. **SIGKILL is not in that list and cannot be.** It is not deliverable to a listener by design. Any package that claims it is claiming something no program can do. ### The cursor, which is the common case ```js import { hideCursor } from 'closeout'; const restore = hideCursor(process.stdout); try { await drawTheSpinner(); } finally { restore(); } ``` `hideCursor` registers the restore **at the same moment it hides**. That pairing is the whole reason it lives here rather than in each renderer: the two cannot drift apart, and a process that dies between them still shows the cursor again. Call `restore()` and the handler unregisters itself, so a program that cleans up normally leaves nothing behind for exit to do. Call it twice, or call it and then die — the cursor is shown once either way. Nothing is written to a non-TTY. Escape sequences in a pipe corrupt the output the pipe exists to carry. ### The alternate screen and raw mode, the same way ```js import { alternateScreen, hideCursor, rawMode } from 'closeout'; const undo = [rawMode(process.stdin), alternateScreen(process.stdout), hideCursor(process.stdout)]; try { await runTheFullScreenApp(); } finally { for (const back of undo) back(); } ``` Each call makes the change and registers its undo in the `restore` phase, so a full-screen program that dies — Ctrl-C, SIGTERM, SIGHUP, a throw, a handler that threw, a deadline that fired — still hands back a terminal with raw mode off, the alternate screen left and the cursor shown, after every other handler has run. Each undo runs once, whoever asks first. Both register on the process-wide instance; pass the object `install()` returned as a second argument to register on that one instead. They are functions rather than methods on that object so that a program which never enters a screen does not bundle them. `rawMode(input)` turns off only what it turned on. If the input is already raw, somebody else owns that state and the call changes nothing, now or at exit. An input that is not a terminal gets no mode change, and a stream that is not a terminal gets no escape, in either direction. ## The problem A program leaves by several doors: returning from `main`, `process.exit`, Ctrl-C, SIGTERM from an orchestrator, SIGHUP when the terminal closes, an uncaught throw, a rejected promise nobody awaited. A handler registered on `'exit'` alone catches one of them. That is why Ctrl-C so often leaves a hidden cursor in your shell, a half-written file, or a lock nobody released. Registering on all the doors is easy. Registering on all of them and running the handlers **exactly once** when two fire at the same moment is where the bugs are, and that is what this package is. ## The deadline ```js import { install } from 'closeout'; const { onExit } = install({ deadline: 5000 }); ``` A handler that awaits something which never resolves — a socket that will not close, a lock nobody releases — turns Ctrl-C into a process the user has to kill **twice**, and the second one is SIGKILL, which runs no handlers at all. Abandoning a slow handler is the better trade. **On a breach the process leaves with the code it was already leaving with, and says which handler did not come back:** ```text closeout: shutdown deadline of 2000ms expired; exiting anyway. Handlers that had not returned: acme:unlock, closeTheDatabase ``` Names come from the function's own `name`, or from a label you give it — `onExit(fn, { label: 'flush-the-audit-log' })` — which is worth doing for the arrow functions, since an anonymous arrow is exactly the shape that hangs. A plugin's handlers are named `":"` for free. `Infinity` and `0` are both **refused at registration**, with a `USAGE`-class error that says what to pass instead. Both reintroduce the failure the package exists to remove: one waits forever, the other gives no asynchronous handler a turn. A caller who genuinely wants either wants a different package. **The default is 2 000 ms, and it is provisional.** Measured 2026-09-14 on darwin arm64 / node 24.13, 100 runs of each of the five cleanup shapes this layer sees: flushing a write stream p99 67.1 ms, closing a server 1.5 ms, killing a child 1.3 ms, restoring the terminal 0.2 ms — and removing a temp directory of 100 files p99 17 818 ms, on a machine at load average 19–22 across 14 cores (p50 161 ms / p99 2 166 ms when re-run alone). Four shapes inside 70 ms, one that is entirely the disk it is queued behind. The number stays 2 000 ms and stays labelled provisional rather than being rounded off a p99 with somebody else's I/O inside it. ## Phases, so the order is not an accident ```js onExit(flushTheLog, 'flush'); // get the data out onExit(releaseTheLock); // let go — the default, `release` // `restore` is closeout's own: raw mode off, alternate screen left, cursor shown — last, always ``` Registration order is the wrong order for a shutdown, and it is the order every incumbent gives you. The handler that hands the terminal back is registered by whichever renderer hid the cursor, at whatever moment it first drew — so anything registered a line later runs *after* the cursor is back, which is to say it cleans up nothing it was registered to clean up. An order that depends on import order is not an order. Three phases, and the names are the sequence: **`flush`** (write the file, drain the log), **`release`** (locks, sockets, children — the default), **`restore`** (the terminal). Phases run *in sequence*: an async handler in `flush` settles before `release` starts. Handlers inside one phase run together, in registration order. Past the deadline the later phases are still **run** — they are only no longer waited for. A handler that hung in `flush` does not get to decide that the cursor stays hidden. ## Plugins ```js import { register, attach } from 'closeout/plugin'; register({ name: 'acme', handlers: [{ name: 'unlock', phase: 'release', run: async () => { await release(); } }], }); attach(closeout.registry); ``` A plugin is one plain object shared by the whole family; closeout keeps `handlers` and ignores every other layer's keys without complaining, so the same object works on whatever subset of the family you have installed. `contributions()` projects the whole shutdown sequence as data — readable without running any of it. A plugin handler may declare `flush` or `release`, and **not** `restore`. Terminal restore is closeout's own last phase; a handler admitted to it could land after the terminal was handed back depending on nothing but which registered first, which is the coincidence phases exist to replace. ## Three guarantees, and what each one costs to get wrong **Exactly once.** Two signals, or a signal and the `'exit'` behind it, are one shutdown. Handlers that run twice release a lock someone else has since taken. **One handler's failure is its own.** A throw is reported and the remaining handlers still run. Shutdown is the worst possible place for an exception to short-circuit a loop, because the handler that restores the terminal is usually registered last. **Bounded.** Shutdown returns on the handlers or on the clock, whichever comes first — and on the handlers when they are all synchronous, not on the clock. **The exit code is yours.** On a signal, a throw or a rejection, how the process leaves is decided at the trigger, before a single handler runs: a handler that sets `process.exitCode = 0` on its way past cannot turn a crash into a success, and a breached deadline leaves the same way rather than with a code invented by the fact that something hung. Every handler is told the code in `report.code`. On `process.exit()` and a normal finish the process is leaving on its own, and Node reads `process.exitCode` *after* the handlers, so a handler must not assign it there. **A signalled process dies of the signal.** Once the handlers have run, closeout removes its own listener and re-raises — so a program killed by Ctrl-C really dies of SIGINT rather than exiting 130. The two are different events to everything upstream of you: `WIFSIGNALED` is true for one and false for the other, so a shell knows to print `^C`, `make` stops a parallel build, a CI runner marks a job cancelled rather than failed, and a supervisor decides whether to restart. 128 + n is the number a shell reports *afterwards*; it is not a status a process can set for itself, and closeout only falls back to it on a runtime that refuses to raise the signal at all (SIGHUP on Windows). **A program that owns the signal keeps it.** The re-raise happens only when no other listener remains, counted after closeout's own comes off. A program with its own `SIGINT` handler gets the cleanup and still decides what happens next — and gets exactly one delivery for one Ctrl-C. > `closeout/exit-hook` is the one place this does not apply. `exit-hook` exits `128 + n` and > listens on SIGINT and SIGTERM only — no SIGHUP — and its own suite grades both, so the > drop-in keeps them. Use closeout's `onExit` rather than the drop-in when a closing terminal > has to reach your cleanup. ## Testing it Everything interesting is in a registry with no process attached: ```js import { createRegistry } from 'closeout'; const registry = createRegistry({ deadline: 10 }); registry.add(handler); await registry.run({ code: null, signal: 'SIGINT' }); ``` And `install({ process: fake })` wires one to something that is not the global process, for a test or for a runner hosting other programs. A `ProcessLike` owes `kill` and `pid` as well as the listener methods, because re-raising a signal is part of the contract above and a fake that could quietly skip it is how the missing re-raise survived two incumbent suites. ## Migrating One import per incumbent: ```diff - import exitHook, { asyncExitHook, gracefulExit } from 'exit-hook'; + import exitHook, { asyncExitHook, gracefulExit } from 'closeout/exit-hook'; ``` ```diff - import restoreCursor from 'restore-cursor'; + import restoreCursor from 'closeout/restore-cursor'; ``` ```diff - import { onExit } from 'signal-exit'; + import { onExit } from 'closeout/signal-exit'; ``` ```diff - import cliCursor from 'cli-cursor'; + import cliCursor from 'closeout/cli-cursor'; ``` ```diff - import onetime from 'onetime'; + import onetime from 'closeout/onetime'; ``` The swap is an import change, not an `overrides` entry. An override points the incumbent's name at closeout's *root*, and the root is closeout's own API — it has one default to give, and five incumbents would each need it. An override for a transitive copy has nowhere to land, so this package does not print one. One thing to know before you swap `exit-hook`: its bound is per hook (`{ wait }`) and the façade keeps that bound rather than imposing closeout's own 2 000 ms deadline, because a drop-in that silently tightens your timeout is not a drop-in. `onExit()` — closeout's own API — is where the bounded shutdown lives. Or let the codemod make the change: `npx burgee migrate --dry-run` lists every import it would rewrite — only drop-ins graded level with their incumbent — and `npx burgee migrate` makes it ([Migrate](https://burgee.interlace.tools/docs/migrate)). ## Compatibility What `signal-exit`, `exit-hook`, `restore-cursor`, `cli-cursor`, `onetime` and `mimic-fn` do between them is one problem — leaving cleanly — and this is one package with no dependencies rather than six with a tree. Five of those paths are built and **graded by the incumbent's own test suite**, unedited apart from the import specifier, through `compat-oracle`. The control column is that suite run against the incumbent itself, which is what says the gate works before it grades us: | subpath | incumbent | control | closeout | | :-- | :-- | --: | --: | | `closeout/exit-hook` | `exit-hook@5.1.0` (8.8 M/wk) | 21 / 21 | 21 / 21 | | `closeout/restore-cursor` | `restore-cursor@5.1.0` (107.5 M/wk) | 6 / 6 | 6 / 6 | | `closeout/signal-exit` | `signal-exit@4` (198.9 M/wk) | 134 / 135 | 134 / 135 | | `closeout/cli-cursor` | `cli-cursor@5.0.0` (126.7 M/wk) | 8 / 8 | 8 / 8 | | `closeout/onetime` | `onetime@8.0.0` (155.0 M/wk) | 6 / 6 | 6 / 6 | The one `signal-exit` case short fails against `signal-exit` itself as well, which is what the control column is for. `mimic-fn` has no row: onetime's suite asserts nothing of it, and `closeout/onetime` keeps the wrapped function's `name` and `length` without exporting its API. `closeout/onetime` is not `closeout/once` renamed: onetime retries a first call that throws, and `once` never runs a handler twice. ## Weight Measured rather than claimed, and the package reaches no other package. Each drop-in weighs no more in a user's bundle than the package it replaces (esbuild, minified, a fixture importing one name, as the [benchmarks](https://burgee.interlace.tools/docs/benchmarks) measure every drop-in): `closeout/exit-hook` is **1,579 B** against `exit-hook@5.1.0`'s 1,645 B, and `closeout/signal-exit` **2,657 B** against `signal-exit@4.1.0`'s 2,856 B. The drop-ins carry the incumbent's behaviour and nothing else; the phase ordering, the bounded runner and the report are the product, on closeout's own `onExit`. ## Benchmarks Every number here is produced by `npm run bench` and published at [burgee.interlace.tools/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks). Graded by the incumbent's own test suite: | suite | passing | | :-- | --: | | `cli-cursor` | 8 / 8 | | `exit-hook` | 21 / 21 | | `onetime` | 6 / 6 | | `restore-cursor` | 6 / 6 | | `signal-exit` | 134 / 135 | Weight, installed and tree-inclusive: **111,003 bytes** against **183,804** for the incumbents it replaces — a ratio of **0.6039** ([measured](https://burgee.interlace.tools/docs/benchmarks)). ## For agents - **One record, two projections.** Every handler is handed `{ path, signal, code, error }`, and `reportToJson()` and `reportToEvent()` project that same record as a `--json` line or an agent event — they cannot disagree with what the handler was told. - **A hang names itself.** A breached deadline prints which handlers had not returned, and exits with the code the process was already leaving with. - **The shutdown is readable as data.** `contributions()` from `closeout/plugin` lists every contributed handler in the order it will run, without running any of it. - **A plugin can be checked before it ships.** `npx closeout check ./unlock.mjs` validates it against the family schema and exits 0, 1 with a code and a fix, or 2 on a usage error. - **The docs are machine-readable** at [closeout.interlace.tools/llms.txt](https://closeout.interlace.tools/llms.txt) and [llms-full.txt](https://closeout.interlace.tools/llms-full.txt). ## API | | | | :-- | :-- | | `onExit(handler, phase \| { phase, label }?)` | register; returns the unregister function | | `once(fn)` | run at most once, first result thereafter — `name`, `length` and `this` kept | | `hideCursor(stream)` | hide and register the restore (in `restore`); returns the show function | | `showCursor(stream)` | show now — idempotent, no-op on a non-TTY | | `alternateScreen(stream, closeout?)` | enter the alternate screen and register leaving it (in `restore`); returns the leave function | | `rawMode(input, closeout?)` | turn raw mode on and register turning it off (in `restore`); an input already raw is left alone | | `install(options)` | wire a registry to a process; `{ deadline, onError, onTimeout, process }` | | `createRegistry(options)` | the registry alone, with no process | | `reportToJson(report)` / `reportToEvent(report)` | the two projections of the one record | | `SIGNALS` | `['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGBREAK']` | | `DEFAULT_DEADLINE` | `2000` | | `PHASES` | `['flush', 'release', 'restore']` | | `DEFAULT_PHASE` | `'release'` | | `EXIT_PATHS` | `['exit', 'beforeExit', 'signal', 'uncaught', 'rejection']` | `run()` resolves with the shutdown's own record: the four fields above plus `timedOut` and `unfinished`, the handlers that had not returned. And the two leaves, for a program that wants one of them and none of the rest ([measured](https://burgee.interlace.tools/docs/benchmarks)): | | | | :-- | :-- | | `closeout/once` | `once(fn)` — 441 B, reaching nothing | | `closeout/cursor` | `showCursor`, `hideCursor`, `alternateScreen`, `bracketedPaste`, `rawMode` (each taking the registrar as a second argument), `HIDE_CURSOR`, `SHOW_CURSOR`, `ENTER_ALTERNATE_SCREEN`, `LEAVE_ALTERNATE_SCREEN`, `ENABLE_BRACKETED_PASTE`, `DISABLE_BRACKETED_PASTE` — 1,786 B, no registry | And from `closeout/plugin`: | | | | :-- | :-- | | `register(plugin)` | validate and keep a plugin's `handlers`; other layers' keys are ignored | | `attach(registry)` | wire every contributed handler into its phase; returns the undo | | `contributions()` | the shutdown sequence as data, in the order it will run | | `registered()` / `reset()` | the plugins, and forgetting them | | `CONTRACT` / `PLUGIN_PHASES` | `1` · `['flush', 'release']` | And the drop-in subpaths, which reproduce their incumbent's API rather than this one: | | | | :-- | :-- | | `closeout/exit-hook` | `exitHook(fn)` (default), `asyncExitHook(fn, { wait })`, `gracefulExit(code?)` | | `closeout/restore-cursor` | `restoreCursor()` (default) | Importing this package attaches nothing. The process-wide instance installs on first use, so a library that imports `closeout` for its types pays nothing. Every export, with its types, is on [closeout.interlace.tools](https://closeout.interlace.tools/docs). ## Where it sits Plugins register under the `handlers` key, against the one schema the whole family shares. `burgee`, `caique`, `controlroom`, `flagstaff` build on it, and it builds on nothing in this family. ## The family Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each owns one job. Six of them — `bellpull`, `closeout`, `linegauge`, `paratext`, `roundel`, `seniority` — depend on nothing; the others depend only on packages in this table, and every dependency points one way, down the [layers](https://burgee.interlace.tools/docs/concepts/family). No package declares a dependency from outside the family; `react` and `react-reconciler` are optional peers of `controlroom`, which npm does not install; `vitest` is an optional peer of `oxmutate`, which npm does not install. [`package-shape-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/package-shape-lock.test.ts) holds that for every manifest, and [`independence-install-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/independence-install-lock.test.ts) installs each package alone and finds nothing but the family packages it declares. Releases are published by one workflow through npm trusted publishing: no npm token is used, and each release carries SLSA provenance, which `npm view dist.attestations` shows. [`trusted-publishing-lock`](https://github.com/ofri-peretz/burgee/blob/main/scripts/trusted-publishing-lock.test.ts) keeps both true. | Package | What it is | Migrates from | | :-- | :-- | :-- | | [burgee](https://burgee.interlace.tools/docs/packages/burgee) | The CLI framework: one declaration, every surface | commander, yargs and meow | | [roundel](https://roundel.interlace.tools/docs) | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors | | [flagstaff](https://flagstaff.interlace.tools/docs) | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 | | [caique](https://caique.interlace.tools/docs) | Prompts that are flags first, and never hang | @inquirer/core | | [linegauge](https://linegauge.interlace.tools/docs) | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line | | [paratext](https://paratext.interlace.tools/docs) | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img | | [seniority](https://seniority.interlace.tools/docs) | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc | | **closeout** (this package) | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime | | [bellpull](https://bellpull.interlace.tools/docs) | Subprocesses, and which executable actually ran | cross-spawn and which | | [controlroom](https://controlroom.interlace.tools/docs) | Keyboard-driven terminal screens, inline or full-screen | ink | | [oxmutate](https://oxmutate.interlace.tools/docs) | Mutation testing on the oxc engine, inside your own Vitest | Stryker | Every migration guide, and the family-wide [compatibility](https://burgee.interlace.tools/docs/compatibility) and [benchmarks](https://burgee.interlace.tools/docs/benchmarks) pages, are on [burgee.interlace.tools](https://burgee.interlace.tools/docs/packages). ## Contributing Issues and pull requests are welcome at [ofri-peretz/burgee](https://github.com/ofri-peretz/burgee/issues); read [CONTRIBUTING.md](https://github.com/ofri-peretz/burgee/blob/main/CONTRIBUTING.md) first. Report a vulnerability privately, as [SECURITY.md](https://github.com/ofri-peretz/burgee/blob/main/SECURITY.md) describes — never in a public issue. ## Licence MIT © Ofri Peretz — see [LICENSE](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/LICENSE). --- # A full-screen program > Raw mode, the alternate screen and a hidden cursor, set up so a Ctrl-C, a crash or a hung cleanup still hands the user their terminal back. Source: https://closeout.interlace.tools/docs/recipes/full-screen-app A full-screen program changes three things about the terminal and has to change all three back on every way out. With closeout each change registers its own undo: ```js title="screen.mjs" import { alternateScreen, hideCursor, onExit, rawMode } from 'closeout'; const undo = [rawMode(process.stdin), alternateScreen(process.stdout), hideCursor(process.stdout)]; onExit(() => console.log('session saved'), 'flush'); let frame = 0; const timer = setInterval(() => { frame += 1; console.log(`frame ${frame}`); if (frame === 2) process.kill(process.pid, 'SIGINT'); // the user presses Ctrl-C }, 10); export function quit() { clearInterval(timer); for (const back of undo) back(); } ``` ```text title="node screen.mjs" signal="SIGINT" frame 1 frame 2 session saved ``` On a terminal, after `session saved` is written, the `restore` phase turns raw mode off, leaves the alternate screen and shows the cursor; then the process dies of SIGINT. In a pipe, as here, there is no terminal to restore and nothing but the program's own lines is written. Three details carry the weight: - **Order of the undos does not matter.** They all go in `restore`, which runs after every `flush` and `release` handler, so saving the session happens on the alternate screen and the terminal comes back last. - **Calling the undos yourself is fine.** `quit()` restores the terminal now and unregisters the exit undos, so a program that quits normally leaves nothing for exit to do. Each undo runs once, whoever asks first. - **Raw mode is only undone if closeout turned it on.** If the program's input was already raw when `rawMode()` was called, somebody else owns that state and it is left alone. ## A second Ctrl-C A user who presses Ctrl-C again while `session saved` is still being written does not get a broken terminal: the second signal waits for the first shutdown, and the process dies of the first signal once `restore` has run. `terminal-restore.test.ts` checks this on a real process for a second SIGINT and for a SIGTERM behind it. ## A save that hangs If saving the session never returns, the [deadline](/docs/guides/deadline) stops waiting for it, names it, and still runs `restore`. Give the handler a label so the line on stderr says what hung: ```js onExit(saveTheSession, { phase: 'flush', label: 'save-the-session' }); ``` --- # A server that stops gracefully > Stop accepting connections on SIGTERM, flush the access log first, and still die of the signal so the orchestrator sees a clean stop — with a deadline that bounds a connection that will not close. Source: https://closeout.interlace.tools/docs/recipes/graceful-server An orchestrator sends SIGTERM and waits a grace period before SIGKILL. In that window a server should flush what it has buffered, stop accepting connections, and leave. ```js title="server.mjs" import { createServer } from 'node:http'; import { install } from 'closeout'; const { onExit } = install({ deadline: 5000 }); const server = createServer((_req, res) => res.end('ok')); const log = []; onExit(() => console.log(`flushed ${log.length} log lines`), 'flush'); onExit( () => new Promise((resolve) => { server.close(() => { console.log('stopped accepting connections'); resolve(); }); }), { label: 'close-the-server' }, ); server.listen(0, () => { log.push('listening'); console.log('listening'); process.kill(process.pid, 'SIGTERM'); // the orchestrator }); ``` ```text title="node server.mjs" signal="SIGTERM" listening flushed 1 log lines stopped accepting connections ``` - **`flush` before `release`.** The log is written while the server is still up, and the close starts only after the flush has settled. - **Set the deadline inside the grace period.** Kubernetes waits 30 seconds by default; a deadline of 5 seconds leaves the rest for the process to actually leave. A keep-alive connection that holds `server.close()` open is then named on stderr — `close-the-server` — instead of eating the grace period and ending in SIGKILL. - **It still dies of SIGTERM.** The orchestrator, and anything reading its status, sees a process that stopped on request rather than one that exited with a code. --- # Incremental migration > Move from exit-hook, signal-exit or restore-cursor to closeout's own onExit a handler at a time: swap the import first, then move handlers, and know what changes when the last one moves. Source: https://closeout.interlace.tools/docs/recipes/incremental-migration The drop-ins and `onExit` share one registry, so a program can use both while it moves. ## 1. Change the import ```diff - import exitHook from 'exit-hook'; + import exitHook from 'closeout/exit-hook'; ``` Nothing else changes: `closeout/exit-hook` passes exit-hook's own suite, 21 of 21. Or let the codemod do it: `npx burgee migrate --dry-run` lists the rewrites, `npx burgee migrate` makes them. ## 2. Move handlers one at a time ```js title="mixed.mjs" import { onExit } from 'closeout'; import exitHook from 'closeout/exit-hook'; exitHook(() => console.log('old hook: flushed the log')); onExit(() => console.log('new handler: released the lock')); setTimeout(() => {}, 10_000); process.kill(process.pid, 'SIGTERM'); ``` Both run, once each, on the same shutdown. While any exit-hook hook is registered, the process leaves the way exit-hook does — `128 + n`, here 143: ```text title="node mixed.mjs" exit="143" old hook: flushed the log new handler: released the lock ``` ## 3. Move the last one ```js title="moved.mjs" import { onExit } from 'closeout'; onExit(() => console.log('flushed the log'), 'flush'); onExit(() => console.log('released the lock')); setTimeout(() => {}, 10_000); process.kill(process.pid, 'SIGTERM'); ``` With no exit-hook hook left, the process dies of the signal, which is what a parent, a shell or a CI runner should see: ```text title="node moved.mjs" signal="SIGTERM" flushed the log released the lock ``` What else changes when the last hook moves: - **SIGHUP and SIGQUIT** reach your cleanup; exit-hook listens on neither. - **A throw and a rejection** reach async handlers too, with the error in the record. - **One deadline for the whole shutdown**, 2000 ms unless you `install({ deadline })`, replaces each hook's `wait`. - **`gracefulExit()`** has no `onExit` counterpart: return from `main`, or call `process.exit()` and keep the handlers that must run there synchronous. ## From signal-exit and restore-cursor `closeout/signal-exit` keeps signal-exit's handler signature, `(code, signal)`; `onExit` gets the record, `({ path, code, signal, error })`. Returning `true` to claim a signal has no `onExit` equivalent: a program that wants to keep running after Ctrl-C installs its own listener, and closeout stands aside ([Signals](/docs/guides/signals)). `closeout/restore-cursor` becomes `hideCursor(stream)`, which hides and registers the restore in one call ([The terminal](/docs/guides/terminal)). --- # Keep the evidence on a crash > Remove a scratch directory on a clean exit and keep it when the program crashed, by reading which door the program left by. Source: https://closeout.interlace.tools/docs/recipes/keep-evidence-on-crash A handler is told how the program ended, so it can clean up differently for a crash than for a clean exit — remove a scratch directory normally, and keep it for the bug report when something was thrown. ```js title="scratch.mjs" import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { onExit } from 'closeout'; const scratch = mkdtempSync(join(tmpdir(), 'build-')); onExit(({ path, error }) => { if (path === 'uncaught' || path === 'rejection') { console.log(`kept the scratch directory: the build failed with "${error.message}"`); return; } rmSync(scratch, { recursive: true, force: true }); console.log('removed the scratch directory'); }); if (process.argv[2] === 'fail') throw new Error('the compiler crashed'); console.log('built'); ``` ```text title="node scratch.mjs" built removed the scratch directory ``` ```text title="node --stack-trace-limit=0 scratch.mjs fail" exit="1" kept the scratch directory: the build failed with "the compiler crashed" [Error: the compiler crashed] ``` The handler runs on the throw path before the error is printed, and the process still exits 1 as Node would have. On `exit` — a `process.exit()` somewhere in the program — `rmSync` is the right call, because nothing can be awaited there: see [Every exit path](/docs/guides/exit-paths#synchronous-and-asynchronous-handlers). --- # Testing shutdown > Test exit handlers without killing the test runner: drive a createRegistry directly, or install closeout against a fake process and assert how it was asked to leave. Source: https://closeout.interlace.tools/docs/recipes/testing-shutdown Everything that decides what a shutdown does lives in a registry with no process attached, so most of it can be tested with no signal at all. ## The registry alone `createRegistry()` takes the same options as `install()`. `registry.run(info)` runs every handler, phase by phase, inside the deadline, and resolves with the report: ```js title="registry.mjs" import assert from 'node:assert/strict'; import { createRegistry } from 'closeout'; const order = []; const registry = createRegistry({ deadline: 50, onTimeout: () => {} }); registry.add(() => order.push('restore'), 'restore'); registry.add(() => new Promise(() => {}), { phase: 'flush', label: 'stuck' }); registry.add(() => order.push('release')); const report = await registry.run({ code: null, signal: 'SIGTERM' }); assert.deepEqual(order, ['release', 'restore']); assert.equal(report.timedOut, true); assert.deepEqual(report.unfinished, ['stuck']); assert.equal(report.path, 'signal'); console.log('ok'); ``` ```text title="node registry.mjs" ok ``` Keep the deadline short in a test: it is the longest a hung handler can make the test wait. ## Against a fake process `install({ process })` wires the same registry to anything that looks enough like a process: `on`, `removeListener`, `listenerCount`, `exit`, `kill`, `pid` and `stderr`. An `EventEmitter` supplies the first three. `kill` is required, not optional, because re-raising the signal is part of the contract, and a fake that could quietly skip it would hide exactly the bug it should catch. ```js title="shutdown-test.mjs" import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { setTimeout as sleep } from 'node:timers/promises'; import { install } from 'closeout'; /** A process that records how it was asked to leave instead of leaving. */ function fakeProcess() { const proc = Object.assign(new EventEmitter(), { pid: 4242, stderr: { write: () => true }, left: [], exit: (code) => proc.left.push(`exit ${code}`), kill: (_pid, signal) => proc.left.push(`died of ${signal}`), }); return proc; } const proc = fakeProcess(); const seen = []; const closeout = install({ process: proc, deadline: 50, onTimeout: (report) => seen.push(`hung: ${report.unfinished}`) }); closeout.onExit(({ path, signal }) => seen.push(`${path} ${signal}`)); closeout.onExit(() => new Promise(() => {}), { phase: 'flush', label: 'upload' }); proc.emit('SIGTERM'); await sleep(100); assert.deepEqual(seen, ['signal SIGTERM', 'hung: upload']); assert.deepEqual(proc.left, ['died of SIGTERM', 'exit 143']); console.log('ok'); ``` ```text title="node shutdown-test.mjs" ok ``` Two things to read off that: - The `release` handler ran while the `flush` handler was still hung, and the breach was reported after it: past the deadline, the later phases are run before closeout leaves. - The fake's `kill` returns, where a real one would have ended the process, so closeout falls through to `exit(143)`. That fallback is what keeps a runtime that cannot raise the signal from staying alive. ## On a real process What only a real process can show — that it dies of the signal, and what reached the terminal — closeout's own suite checks by spawning a child: see [`signal.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/signal.test.ts) and [`terminal-restore.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/terminal-restore.test.ts). Every example on this site is run the same way. --- # Why closeout > closeout against signal-exit, exit-hook and restore-cursor, one capability per row, every cell linked to the test, grade or source that proves it. Source: https://closeout.interlace.tools/docs/why-closeout signal-exit runs a handler when a process ends, exit-hook lets that handler be async, and restore-cursor shows the cursor again. closeout does all three — through drop-in paths graded by each one's own test suite — and adds what none of them has: one handler on every exit path told which one it was, phases that put the terminal back last, raw mode and the alternate screen restored as well as the cursor, and a deadline that ends a hung shutdown and names the handler that hung. The table below is the whole comparison. Every mark links to its evidence: a test in this repository for ours, and for theirs the source file of the exact version compat-oracle grades, or that package's own test suite. `scripts/capabilities-lock.test.ts` fails the build when a cited test no longer contains the title it is cited for, when a source no longer contains the line it is quoted for, or when a source we say lacks something has gained it. ✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades. ### Every way out | Capability | **closeout** | signal-exit | exit-hook | restore-cursor | cli-cursor | | :-- | :-- | :-- | :-- | :-- | :-- | | **One registry for every exit path** — A handler registered once runs on a signal, an uncaught throw, an unhandled rejection, an emptied event loop (`beforeExit`) and `process.exit()`, so no door out skips the cleanup. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/restore.test.ts) | [◐ no `beforeExit` trigger, so an async handler never gets the emptied loop's time; a throw or a rejection reaches handlers only through the `exit` event behind it](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [◐ no SIGHUP, SIGQUIT, uncaughtException or unhandledRejection listener; a throw reaches only the synchronous hooks, through `exit`](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— registers one handler of its own, the cursor restore, and takes none of yours](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [— hides the cursor and hands its restore to restore-cursor; takes no handler of yours](https://cdn.jsdelivr.net/npm/cli-cursor@5.0.0/index.js) | | **The handler is told which door, and what was thrown** — Every handler gets `{ path, signal, code, error }`, so it can tell Ctrl-C from a crash and keep the temp directory for a bug report. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/registry.test.ts) | [✗ a handler gets `(code, signal)`; a throw arrives as code 1 with no error](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ a hook gets the exit code alone](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— takes no handler of yours](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [— takes no handler of yours](https://cdn.jsdelivr.net/npm/cli-cursor@5.0.0/index.js) | | **Exactly once when two doors fire together** — Ctrl-C twice, or a signal and the `exit` behind it, is one shutdown, so a lock is never released twice. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/registry.test.ts) | [✓](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✓](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✓ registers once, and signal-exit's emitter runs the handler once](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [✓ through restore-cursor, which it depends on](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | | **One handler's throw does not skip the rest** — A handler that throws is reported and the handlers after it still run, so the one that restores the terminal is not the one that gets skipped. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/registry.test.ts) | [✗ calls each handler in a plain loop; a throw ends the loop, and the handlers after it do not run](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ calls each synchronous hook in a plain loop; a throw ends the loop](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— runs one handler of its own](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [— runs no handler of yours](https://cdn.jsdelivr.net/npm/cli-cursor@5.0.0/index.js) | ### A shutdown that ends | Capability | **closeout** | signal-exit | exit-hook | restore-cursor | cli-cursor | | :-- | :-- | :-- | :-- | :-- | :-- | | **A deadline that names the handler that hung** — A handler that never returns cannot hold Ctrl-C hostage: after the deadline (2000 ms by default) the process leaves with the code it was leaving with and prints which handlers had not returned. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/matrix.test.ts) | [✗ awaits nothing a handler returns, so an async cleanup is abandoned rather than bounded](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [◐ each async hook carries its own `wait`, the longest one bounds the rest, and the forced exit names no hook](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— one synchronous write; nothing to bound](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [— synchronous writes; nothing to bound](https://cdn.jsdelivr.net/npm/cli-cursor@5.0.0/index.js) | | **Phases: flush, release, then restore last** — Handlers run in `flush`, `release`, `restore` order, each phase awaited before the next, so the terminal comes back after the log is written whichever registered first. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/plugin.test.ts) | [◐ two tiers, `exit` then `afterExit`, in registration order within each; nothing is awaited between them](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ registration order, every synchronous hook before any asynchronous one](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [◐ its one restore goes last through signal-exit's `alwaysLast`; there are no phases for anything else](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [◐ its one restore goes last through restore-cursor and signal-exit's `alwaysLast`; there are no phases for anything else](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | | **The restore still runs after a handler hung** — Past the deadline the later phases are still run, just no longer waited for, so a hung `flush` does not decide that the cursor stays hidden. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/deadline.test.ts) | [— has no deadline to run past](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [◐ every async hook is started at once, so a restore hook does run, but beside the hung one rather than after it](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— has no deadline to run past](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [— has no deadline to run past](https://cdn.jsdelivr.net/npm/cli-cursor@5.0.0/index.js) | ### The terminal | Capability | **closeout** | signal-exit | exit-hook | restore-cursor | cli-cursor | | :-- | :-- | :-- | :-- | :-- | :-- | | **Cursor, raw mode and the alternate screen restored** — `hideCursor`, `rawMode` and `alternateScreen` make the change and register its undo in the `restore` phase, so a full-screen program that dies by any door hands back a usable terminal. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/cursor.test.ts) | [✗ writes nothing to a terminal; the restore is a handler you write](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ writes nothing to a terminal; the restore is a hook you write](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [◐ the cursor only; raw mode and the alternate screen are left as they were](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [◐ the cursor only; raw mode and the alternate screen are left as they were](https://cdn.jsdelivr.net/npm/cli-cursor@5.0.0/index.js) | | **A second signal waits for the first shutdown** — A second Ctrl-C, or a SIGTERM behind it, while a handler still holds the first shutdown waits for that shutdown, so the restore runs before the process dies. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/terminal-restore.test.ts) | [— handlers run synchronously inside the first signal's listener, so no second signal arrives mid-shutdown](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ the listener is `once`: a second SIGINT while an async hook runs finds none, and the process dies there](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [— one synchronous write inside signal-exit's handler](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [— one synchronous write inside signal-exit's handler, through restore-cursor](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | | **No escape sequences into a pipe** — A stream that is not a terminal gets no cursor or screen sequence on the way in or out, so a log carries only what the program wrote. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/cursor.test.ts) | [— writes nothing to any stream](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [— writes nothing to a terminal](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✓](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [✓](https://cdn.jsdelivr.net/npm/cli-cursor@5.0.0/index.js) | ### Exit status | Capability | **closeout** | signal-exit | exit-hook | restore-cursor | cli-cursor | | :-- | :-- | :-- | :-- | :-- | :-- | | **A signalled process dies of the signal** — After the handlers run, the signal is re-raised, so a shell prints `^C`, `make` stops, and a CI runner marks the job cancelled rather than failed. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/signal.test.ts) | [✓](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ exits 128 + n, so a parent sees an exit code, not a signal; `closeout/exit-hook` keeps that, as exit-hook's suite requires](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✓ through signal-exit, which it depends on](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✓ through restore-cursor and signal-exit](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | | **A program that owns the signal keeps it** — When the program has its own listener for the signal, the handlers run and the program still decides what happens next, with one delivery for one Ctrl-C. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/install.test.ts) | [✓](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗ exits after the hooks, whatever other listeners the program has](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✓ through signal-exit, which it depends on](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✓ through restore-cursor and signal-exit](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | ### Agents and plugins | Capability | **closeout** | signal-exit | exit-hook | restore-cursor | cli-cursor | | :-- | :-- | :-- | :-- | :-- | :-- | | **The shutdown as one `--json` line or an agent event** — `reportToJson()` and `reportToEvent()` project the record the handlers were given, with `timedOut` and the handlers that hung, so a log and an agent read the same facts. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/report.test.ts) | [✗](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✗](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [✗](https://cdn.jsdelivr.net/npm/cli-cursor@5.0.0/index.js) | | **Plugins: named handlers in a phase, readable as data** — A plugin contributes named handlers to `flush` or `release`, is validated against the family schema, and `contributions()` lists the whole shutdown in order without running it. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/plugin.test.ts) | [✗](https://cdn.jsdelivr.net/npm/signal-exit@4.1.0/dist/mjs/index.js) | [✗](https://cdn.jsdelivr.net/npm/exit-hook@5.1.0/index.js) | [✗](https://cdn.jsdelivr.net/npm/restore-cursor@5.1.0/index.js) | [✗](https://cdn.jsdelivr.net/npm/cli-cursor@5.0.0/index.js) | ### Compatibility | Capability | **closeout** | signal-exit | exit-hook | restore-cursor | cli-cursor | | :-- | :-- | :-- | :-- | :-- | :-- | | **Passes exit-hook's own test suite** — `closeout/exit-hook` is graded by exit-hook 5.1.0's own tests, unedited, so changing the import keeps exit-hook's behaviour, 128 + n exit codes included. | [✓ 21 / 21 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/exit-hook.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cli-cursor/test.js) | | **Passes restore-cursor's own test suite** — `closeout/restore-cursor` is graded by restore-cursor 5.1.0's own tests, which spawn a child per case and check which stream the cursor comes back on. | [✓ 6 / 6 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/restore-cursor.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cli-cursor/test.js) | | **Passes cli-cursor's own test suite** — `closeout/cli-cursor` runs cli-cursor 5.0.0's own suite, unmodified: 8 of 8, the same as cli-cursor itself, including the case that spawns the module and expects it to print nothing. | [✓ 8 / 8 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/cli-cursor.json) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cli-cursor/test.js) | | **Passes signal-exit's own test suite, level with signal-exit** — `closeout/signal-exit` passes 134 of the 135 cases of signal-exit 4.1.0's own suite, and signal-exit itself passes the same 134: the one case short fails for both on current Node. | [◐ 134 / 135 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/signal-exit.json) | [◐ its own suite, the control run, passes 134 of 135: this case fails on Node 22, 24 and 26, all released after signal-exit's last release](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/signal-exit/test/signal-exit-test.ts) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/exit-hook/test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/restore-cursor/index.test.js) | [— a different API](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/cli-cursor/test.js) | ## Reading it - **"ours" means closeout's own API**, `onExit` and `install()`. The three drop-ins keep their incumbents' behaviour, which is what their grades measure: `closeout/exit-hook` exits `128 + n` and ignores SIGHUP because exit-hook does. [Compatibility](/docs/drop-ins) lists what each drop-in keeps. - **Parity rows are here too.** signal-exit and exit-hook both run a handler once when two doors fire together, and signal-exit re-raises a signal as closeout does. A row where they match us is a row a reader would otherwise have to go and check. - **— does not apply** is not a soft ✗. restore-cursor takes no handler of yours, so rows about your handlers do not apply to it; the cell says why. - **signal-exit's grade is level with signal-exit.** `closeout/signal-exit` passes 134 of the 135 cases of signal-exit's own suite, and signal-exit passes the same 134. The one case both fail, `does not exit if user handles signal`, fails for signal-exit itself on every Node released since its last version. ## What is not in the table A row goes in only when every cell of it can be proved. These were left out: - **"The exit code is the program's."** On a signal, a throw and a rejection, closeout decides how the process leaves before any handler runs, and `matrix.test.ts` proves it for the deadline. On `process.exit()` a handler that assigns `process.exitCode` still changes the code, because Node reads it after the `exit` listeners — see [Signals and exit status](/docs/guides/signals#the-exit-code). The row would not be true on every path, so it is not a row. - **`npx closeout check`.** The plugin checker ships and is shown on [Plugins](/docs/guides/plugins), where the example is run by this site's tests, and [`check.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/check.test.ts) tests the command itself: its arguments, its report and its refusals. - **Weight.** Zero dependencies is a fact of the manifest, and the per-entry byte budgets are asserted by [`weight.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/closeout/src/weight.test.ts) and published on [Benchmarks](https://burgee.interlace.tools/docs/benchmarks), measured the same way on both sides. They are not a yes-or-no capability. `closeout/exit-hook` is larger than exit-hook, and closeout's README says so. - **SIGKILL.** No package can run a handler on it, closeout included; a row would be four ✗s that say nothing about any of them.