npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tui-saver

v0.4.3

Published

A terminal ASCII/braille screensaver that keeps the host awake

Readme

tui-saver

tui-saver-show.gif

한국어 문서

A terminal screensaver that keeps the machine awake while it runs. 18 scenes, drawn with characters. macOS, Windows and Linux. No runtime dependencies.

The lock lasts exactly as long as the animation is on screen. Quit it and the machine sleeps normally again; no power setting is changed.

Install

npm i -g tui-saver           # needs Node 22 or newer
npm i -g tui-saver@latest    # to update

An already-running instance keeps the version it started with. Quit and restart it after updating.

Or from a clone, with nothing to build. This needs Node 23.6 or newer, which runs TypeScript without a build step — the installed package is compiled and does not:

git clone https://github.com/aciddust/tui-saver
cd tui-saver
node src/main.ts

Below, tui-saver means node src/main.ts if you are running from a clone.

What changed between versions is in CHANGELOG.md.

Usage

tui-saver                    # start
tui-saver --list             # what scenes there are
tui-saver --scene globe      # one scene, no cycling
tui-saver --only globe,fire  # a shorter playlist
tui-saver --for 90m          # stop after 90 minutes
tui-saver --until 18:00      # stop at 18:00
tui-saver --while 4242       # stop when that process exits
tui-saver --doctor           # is the lock actually held?
tui-saver --help             # every option

Scenes change every 22 seconds. f pins the one on screen.

Keys

n / →        next scene            m       cycle render mode
p / ←        previous scene        c       cycle palette
1-9          jump to scene         r       cycle ascii ramp
space        pause                 [ ]     slower / faster
f            pin current scene     0       reset speed
s            shuffle playlist      h       hide status bar
+            add 15m to the limit  ?       help
q            quit

The status bar

 3/18  globe  3h42m  bat 74% ac  half  ice  32fps    awake:os   f:pin  ?:help  q:quit

| field | meaning | | ------------- | ------------------------------------------------------------------- | | 3/18 globe | scene number and name | | 3h42m | how long this run has been up; with --for or --until, time left | | bat 74% ac | charge remaining; ac means it is not draining | | while:4242 | the process being waited for, with --while | | ssh:host | the lock is held there, not on the machine you are typing on | | awake:os | the OS confirms the lock is held | | awake:self | the watcher says so; nothing independent has agreed (Windows) | | awake:live | the watcher is running, and that is all that is known | | awake:FAIL | not holding it; a banner and the terminal bell say so as well |

A narrow terminal drops the render mode, palette and frame rate first. The awake indicator is the last thing to go.

Staying on one scene

The playlist advances every 22 seconds by default. Three ways to stop it:

  • **f while it's running** — pins whatever is on screen and stops the clock. The status bar switches to pinned / f:unpin. Press it again to resume cycling. n and p still work while pinned, so you can step by hand.
  • **--scene <id>** — start with only that scene, nothing to advance to.
  • **--duration 0** — keep the whole playlist but never advance automatically; n / p / 1-9 drive it.

--only globe,fire is the in-between case: a short playlist of just the scenes you want, still cycling.

Stopping by itself

Nothing bounds a run by default, and that is deliberate: the lock is held for exactly as long as the animation is on screen, and a full-screen animation is hard to forget about. But a run you walked away from outlives its reason, so it can be given an end:

tui-saver --for 90m        # 90s, 45m, 2h, 1h30m
tui-saver --until 18:00    # the next 18:00 — tomorrow's, if today's has passed
tui-saver --while 4242     # end when that process does

--while is the honest one. Nobody holds a sleep lock for its own sake; they hold it because something is running. caffeinate -w is exactly that tool and almost nobody reaches for it — partly because its own rules throw it away. The man page: -w "is ignored when used with utility option", and -t likewise, so caffeinate -t 60 -w 1234 make silently discards both. Here --while and --for both bound the run and whichever comes first ends it; neither is dropped for the other's benefit.

A pid that is not running is refused, rather than accepted and returned from instantly the way caffeinate -w treats a dead pid.

There is no --while <command> form, deliberately. caffeinate <command> can hand the terminal to its child because it draws nothing itself; this program owns the screen, so a child's output would be either buried under the animation or thrown away. Run the command in another pane and pass its pid.

The status bar counts down, goes amber for the last minute, and + adds fifteen minutes if you are still there. With no limit it shows the other number instead: how long this has been up, and therefore how long the machine has been kept awake.

caffeinate -t already does the first of these, and this is not pretending otherwise. What it adds is the same thing on the other two platforms — systemd-inhibit has no timeout option and neither does SetThreadExecutionState — and a timer you can see. A countdown nobody can read is how a limit ends up extended by guesswork.

Not flattening the battery

caffeinate has no idea what it is plugged into. -s is the closest it comes, and the man page says the assertion "is valid only when system is running on AC power" — so it silently does nothing on battery, which is exactly the case where someone shut the lid and walked off believing the machine would stay up.

The charge is read once a minute and shown in the status bar: bat 74% ac on mains, bat 74% when it is going down. Below --battery-floor while discharging, the run warns for ten seconds and then releases the lock and exits — a machine at 12% in a bag is the one outcome nothing else here would have prevented.

tui-saver --battery-floor 25   # default 15
tui-saver --battery-floor 0    # read it, show it, never act on it

Only a battery that is actually going down counts, so working at 8% on mains is left alone. A machine with no battery reports none and the guard does nothing, which is also what happens on a platform with no reader or when the query fails — all four want the same answer.

Staying awake

Each platform has some way to say "don't idle out while I'm running", and all three are expressed the same way here: launch a small helper that takes the lock and then watches our pid, so it releases on its own when we go away.

| | mechanism | | ------- | ----------------------------------------------------------------------- | | macOS | caffeinate -dis -w <pid> | | Windows | powershell -> SetThreadExecutionState, then Wait-Process <pid> | | Linux | systemd-inhibit --what=idle -- tail --pid=<pid> -f /dev/null |

That indirection is the whole design. A lock held by us leaks if we are killed with SIGKILL or TerminateProcess, because no cleanup handler runs. A lock held by a watcher cannot leak: the watcher notices the pid is gone, and if the watcher itself is killed the OS drops the lock with the process. There is no path that leaves a machine permanently unable to sleep.

Which machine

Over ssh, everything here works and none of it is about the computer in front of you: the lock, the battery reading and the idle timer all belong to the far end. The status bar says ssh:<host> and --doctor names it, because a program whose argument is that its state is visible should not be vague about whose state it is showing.

This mattered less than it should have until recently. The Linux backend failed silently without a login session — which is exactly the ssh case — so the question rarely came up. Now it works, and keeping the wrong machine awake is something that can actually happen.

When nobody is looking

A tmux pane can hold a running animation nobody can see — the client detached, or it is simply on another window. That is this program's own failure mode reached from the inside: the lock is held and its indicator is not on any screen.

Inside tmux, the time spent unseen is counted, and reported the moment somebody is there to read it:

held for 42m with nothing on screen - any key dismisses

Nothing is released and nothing is stopped. Detaching from a long job on purpose is a reasonable thing to do, and ending the run for it would be worse than the problem. --for remains the structural answer; this is the part that tells you it happened.

Measured against tmux 3.7b, since which format variable to ask for is not obvious:

| | session_attached | window_active | pane_active | | ---------------------- | ------------------ | --------------- | ------------- | | detached | 0 | 1 | 1 | | attached, other window | 1 | 0 | 1 | | attached, our window | 1 | 1 | 1 |

So the pair that matters is session_attached and window_active. pane_active is deliberately not consulted: a split pane that is not the active one is still on screen, and counting it as unseen would be wrong.

Outside tmux there is no portable way to ask whether a window is visible, so nothing is claimed.

Knowing it is really held

A running watcher is not proof. The request can be refused, or dropped by something outside this program, and the process looks identical either way. Three things stand between "we asked" and "it is held":

  • The OS is asked again. Every 90 seconds, with the platform's own tool — pmset -g assertions, systemd-inhibit --list. An answer that no longer names the lock fails the run even though the watcher is still alive. Being unable to ask is treated differently from being told no: the answer goes stale rather than becoming a failure.
  • A watcher that dies is replaced. Once. A watcher that cannot stay up is a fact to report, not a spawn loop.
  • Failure is not hideable. The status bar can be hidden with h or never shown at all with --no-hud; the warning across the top row cannot be, and the bell rings once when the lock is lost. A run that stopped holding what it promised exits 1 and says so on stderr.

The status bar grades the evidence rather than implying one answer for all three platforms:

| label | what it means | | ------------ | -------------------------------------------------------------- | | awake:os | the OS itself reports the lock, recently | | awake:self | the watcher says it holds it; nobody independent has agreed | | awake:live | the watcher is running, and that is the whole of what we know | | awake:FAIL | not holding it |

awake:self is what Windows gets. powercfg /requests will not print the request table without an Administrator prompt, and there is no unelevated way to ask Windows about an execution-state request — so instead the watcher re-asserts the flags on every wait iteration and prints a line each time that succeeds. Weaker than the OS agreeing, far stronger than a live pid. It also replaced a fixed 3.5-second delay: --doctor used to guess how long PowerShell needed to compile its P/Invoke shim before the lock existed to be asked about.

--require-awake refuses to draw anything at all unless the lock reaches awake:os or awake:self, which is what you want when wrapping work that must not be interrupted.

--doctor holds the same lock, waits for the watcher to confirm it, and then prints which grade of evidence it got alongside the platform's own view of it — so you never have to take this file's word for any of it.

What else is holding one

The tool that answers "is my lock held?" lists everybody else's too, so --doctor reports them. Real output from the machine this was written on:

  others holding a lock:
    pid   366  powerd          3h02m  PreventUserIdleSystemSleep, InternalPreventDisplaySleep
    pid 16638  caffeinate      3h01m  PreventUserIdleSystemSleep, PreventUserIdleDisplaySleep
    pid   425  WindowServer    9m40s  UserIsActive
    pid 39289  caffeinate      1m11s  PreventUserIdleSystemSleep

Longest first, because nothing else on this machine will ever mention the caffeinate -dis somebody left running on Tuesday. This program's own lock and its watcher are left out — a watcher is recognised by the pid it was created for, not by its name.

Exactly one thing gets a verdict:

    pid 16638  caffeinate      3h01m  PreventUserIdleSystemSleep
      LEAKED: taken for pid 16577, which no longer exists - release it with: kill 16638

caffeinate -w records who it is waiting for, so a dead target is a fact. Age is not. The first version of this flagged anything over an hour, and the first machine it ran on had macOS's own powerd holding PreventUserIdleSystemSleep for three hours, which is what powerd does. The flag taught nobody anything and buried the lines that mattered. Durations are reported and sorted instead.

What each platform can say differs, and the output does not pretend otherwise:

| query | pid | process | duration | on behalf of | | ------------------------------ | --- | --------- | -------- | ------------ | | pmset -g assertions | yes | yes | yes | yes | | systemd-inhibit --list | yes | yes | no | no | | powercfg /requests | no | path only | no | no |

Where there are no durations the column is dropped rather than left blank, and a line says so. Windows needs an Administrator prompt to answer at all and names no pid, so our own watcher cannot be told apart from any other PowerShell there.

Verifying it yourself

node tools/check-awake.ts

Static checks of all three platforms' watcher commands, then a live check on the current one: hold the lock against a throwaway process, ask the OS whether it is held, kill that process outright, and confirm the watcher lets go. Run it once on each platform you ship to — or let .github/workflows/verify.yml do it, which runs this tool on macOS, Windows and Linux runners on every push.

On Windows the OS query (powercfg /requests) needs an Administrator prompt and is reported rather than failed — but the kill test, which is the load-bearing one, needs no elevation anywhere.

Platform status

All three are now verified by CI on every push, which is a change from how this file used to read.

macOS — verified. Assertions confirmed held via pmset -g assertions, and confirmed released after kill -9.

Windows — verified. The author had no Windows machine, so this was written blind and checked as data; a Windows runner now executes it, and powercfg /requests confirms the request table on a runner that happens to be elevated. It also turned up something no amount of reading would have: PowerShell serialises a redirected stderr as CLIXML, so a failure reason taken from there arrives as "#< CLIXML" and nothing else. The watcher reports on stdout instead. What is verified is everything checkable as data: the exact UTF-16LE bytes PowerShell receives, the here-string and brace structure, the flag values (ES_CONTINUOUS|ES_SYSTEM_REQUIRED|ES_DISPLAY_REQUIRED to hold, ES_CONTINUOUS alone to release), and that the release runs in a finally. Run tools/check-awake.ts on Windows before shipping.

Linux — verified in CI, and it found something. The watcher used to ask for --what=idle:sleep, and on a machine with no login session polkit refuses the sleep half with "Access denied". A systemd-inhibit request is atomic, so the idle half went down with it: over SSH, headless, or anywhere without a seat, this program held nothing at all while reporting that it did. It now asks for --what=idle, which is granted with or without a session.

Narrowing it gives up less than it sounds. Blocking sleep would also block an explicit suspend — a lid close, systemctl suspend, the power button — which is someone asking for something rather than the idle timer taking it. macOS makes the same concession from the other end: caffeinate's -s is documented as valid only on AC power, so its strongest component is silently dropped on battery.

The trade is that a desktop-session user can no longer block an explicit suspend through this program. That was never what a screensaver should be doing.

What none of them do

Stop the screen saver. It runs off a separate idle timer and would cover the animation. --defeat-screensaver mitigates it per platform — a short caffeinate -u on macOS, an xdg-screensaver reset on Linux, a single {F15} keystroke on Windows. Best-effort: a screen lock enforced by security policy or group policy is not something a user-space program overrides, and shouldn't be.

Two of the three are now measured rather than assumed, by tools/check-awake.ts on every push:

  • macOS — after a pulse, pmset -g assertions shows a UserIsActive assertion named for caffeinate, and it disappears when the two-second timeout expires.

  • WindowsGetLastInputInfo moves. That is the measurement that settles it, because the screen saver runs off that timer, and it had never been taken:

    before          56000
    after 3s idle   56000     (nothing else is generating input)
    after the pulse 1978953

    The watcher only pulses once per 45-second wait, too slow to sit through in a check, so the mechanism is measured directly and the static checks confirm the watcher script carries that exact keystroke line — and carries it only when asked.

  • Linuxxdg-screensaver reset reports nothing and needs a display, so the check fires it and says plainly that it cannot confirm it landed.

Note the Windows pulse is the only part of this that touches the rest of the desktop. The keystroke code is omitted from the generated script entirely unless you pass the flag, rather than being present and skipped at runtime — so if you don't ask for it, there is nothing there to read or to scan.

Keep a laptop awake with the lid shut. No lock overrides clamshell sleep unless the machine is on AC power with an external display attached.

On a platform with no backend the animation still runs; the status bar reads awake:n/a.

Windows notes

Two things bite on Windows that don't elsewhere, both fixed but worth knowing:

Colour. Nothing on Windows sets TERM, and the usual "no TERM means no colour" heuristic therefore sentenced every Windows terminal to monochrome — including Windows Terminal, which does full 24-bit. Windows is now decided before that rule and defaults to truecolor, which both Windows Terminal and conhost have supported since Windows 10 1703.

Braille glyphs. The legacy console's default font is Consolas, which has no Braille Patterns coverage, so the braille scenes would render as rows of replacement boxes. There is no escape sequence that asks a terminal about font coverage, so it is inferred: if nothing indicates a modern host (WT_SESSION, TERM_PROGRAM, ConEmu, WezTerm, Alacritty, or any TERM), the braille scenes — and only those — are redirected to half-block mode, which keeps sub-character vertical resolution using a glyph every font has. Everything else renders unchanged.

Override the guess either way with --braille / --no-braille.

For the best result tell users to run it in Windows Terminal with Cascadia Mono, which covers braille and does truecolor.

What it runs

Every part of keeping a machine awake is a call to a tool the OS already ships, and the whole list is under Staying awake. Nothing is downloaded, no script is written to disk, and no power setting is changed. Four details are the kind a careful reader stops on, so they are written down here rather than left to be inferred.

Nothing goes through a shell. Every child process is started with an argument array — spawn/execFile, never a command string, never shell: true — so there is no quoting layer for anything to be injected through. The only environment value that ever becomes a command argument is TMUX_PANE, handed to tmux as a single argument.

The Windows watcher looks worse than it is. It is a PowerShell child launched with -ExecutionPolicy Bypass -EncodedCommand <base64>, which is a shape malware also wears. What is inside it is in awake.ts: the script is built in this process from a pid and a boolean, passed as an argument, and never touches disk — so the execution policy has no file to protect, nothing on disk can be swapped out from under it, and the flag is there only so that a setting meant for downloaded .ps1 files cannot quietly take the lock away. -EncodedCommand is base64 UTF-16LE, which sidesteps every quoting layer between the two processes, and is why that script is readable in the source instead of escaped past legibility.

--defeat-screensaver is the only part that touches the rest of the desktop. On Windows it sends one {F15} keystroke every 45 seconds, and that keystroke line is left out of the generated script entirely unless the flag is passed — see What none of them do.

Verification is what needs elevation, not the lock. powercfg /requests will not print the request table without an Administrator prompt, and systemd-inhibit --list can be refused by polkit on a machine with no login session. --doctor reports the refusal rather than calling the lock failed, so read a failure there as "could not ask" before reading it as "not held". Those messages come from the OS and can carry a path or a username, which is worth a look before pasting --doctor output into a public issue.

Scenes

| id | mode | what it is | | -------------- | ------- | ------------------------------------------------------------------------------- | | torus | ascii | the donut — point-sampled parametric torus, z-buffered, Lambert-shaded | | cube | ascii | solid cube, flat-shaded faces, only the visible faces' edges stroked | | tesseract | braille | 4-cube rotating in the xy/zw/xw/yz planes, projected through w | | mobius | ascii | one-sided surface with two-sided shading and a band that travels around it | | globe | half | ray-cast Earth: real coastlines, day/night terminator, city lights, ocean glint | | waves | braille | wireframe height field of interfering travelling ripples | | plasma | half | five sine fields summed and read through a cyclic palette | | tunnel | half | perspective-correct polar tunnel, wandering vanishing point | | starfield | braille | stars streaking past, with periodic warp bursts | | metaballs | half | seven blobs merging and splitting, contour-banded | | fire | half | the 1993 DOOM fire algorithm, in colour | | lorenz | half | the butterfly | | aizawa | half | a spindle wound with a torus | | thomas | half | cyclically symmetric, three-fold and looping | | mandelbrot | half | endless dive into the seahorse valley, smooth-iteration banded | | harmonograph | braille | four damped pendulums inking a figure, then starting a new one | | hilbert | braille | space-filling curve drawing itself, refining order by order | | raymarch | half | smooth-union SDF primitives with shadow and ambient occlusion |

Render modes

Scenes never touch characters. They draw into a sub-character pixel grid, and the canvas turns that into cells one of three ways:

  • braille — 2×4 pixels per cell via U+28xx, so eight times the resolution of plain text. One bit of coverage per dot and one colour per cell. Best for line work: wireframes, curves, particles.
  • half — 1×2 pixels per cell using with the lower pixel as the background colour. Full colour per pixel. Best for dense fields and anything where a smooth gradient is the point.
  • ascii — one pixel per cell mapped onto a character ramp. Best for luminance-shaded solids, and the only mode that says anything without colour.

Each scene declares the one it suits; m cycles the override live, --mode forces it for everything. Braille and half-block pixels come out roughly square; ascii pixels are twice as tall as they are wide, and the canvas exposes that correction so a scene can draw a circle that actually looks round.

Options

playback
  --scene <id>            show only this scene
  --only <id,id,...>      restrict the playlist
  --duration <seconds>    seconds per scene (0 = never advance)   [22]
  --transition <seconds>  dissolve length                         [0.9]
  --fps <n>               target frame rate                       [32]
  --speed <n>             time multiplier                         [1]
  --shuffle               randomise the playlist order
  --for <90m|2h|1h30m>    end the whole run after this long
  --until <HH:MM>         end the whole run at this time of day
  --while <pid>           end the whole run when that process exits

look
  --mode <braille|half|ascii>   force a render mode for every scene
  --palette <id>                mono amber green ice ember magma viridis cyber rainbow
  --ramp <classic|soft|blocks|dense>
  --color <truecolor|256|mono>  override colour depth detection
  --braille / --no-braille      override the braille-font guess (see Windows notes)
  --no-hud                      start with the status bar hidden

staying awake
  --no-awake              hold no sleep lock at all
  --require-awake         exit rather than run without a confirmed lock
  --battery-floor <pct>   end the run below this charge on battery (0 off)  [15]
  --defeat-screensaver    also declare synthetic user activity
  --doctor                report what the OS says about the lock

Colour depth is detected from COLORTERM/TERM/TERM_PROGRAM (and defaults to truecolor on Windows, where none of those exist), and NO_COLOR is honoured. With colour off, half-block scenes fall back to the ascii ramp, since a half-block cell with no colour is just a solid block.

Implementation notes

src/core/canvas.ts   pixel grid -> cells, the three render modes
src/core/screen.ts   cell diffing and ANSI output
src/core/raster.ts   projection, antialiased lines, z-buffered triangles
src/core/color.ts    palettes, packing, xterm-256 quantisation
src/core/noise.ts    hashing and a small xorshift
src/core/scene.ts    the scene contract
src/awake.ts         per-platform sleep-lock backends and --doctor
src/battery.ts       per-platform battery readers, parsers exported
src/cli.ts           the option table, usage text and parser
src/session.ts       how long a run lasts and when it stops by itself
src/ui.ts            status bar, help overlay, dissolve
src/scenes/          one file per scene

A few decisions worth knowing about:

Frames are diffed, not repainted. Screen keeps the last frame and emits only changed runs, coalescing SGR sequences within a run, emitting only the half of the colour state that changed, and tolerating short clean gaps rather than paying for another cursor move. Colours are also quantised to 6 bits per channel, which makes neighbouring cells in a gradient byte-identical so runs collapse — worth 15–30% and invisible in a character grid.

That gets most scenes to well under 1 MB/s at 132×38 truecolour:

| | KB/frame | at 32 fps | | ------------------------------------ | -------- | --------- | | tesseract (braille, sparse) | 6 | 0.19 MB/s | | torus (ascii) | 17 | 0.52 MB/s | | globe (half) | 26 | 0.82 MB/s | | plasma (half, every pixel changes) | 136 | 4.2 MB/s |

A full-screen per-pixel shader like plasma is the worst case and stays expensive: every cell needs both a foreground and a background colour, and neighbours differ too much to coalesce. Rather than chase it with coarser quantisation, the writer drops frames when stdout has not drained — so a slow terminal degrades to a lower frame rate instead of falling behind and smearing. If a scene feels sluggish, --fps 20 or --mode ascii cuts the traffic directly.

The dissolve works on cells, not pixels. That is what lets a braille scene melt into a half-block one — neither has to know the other's mode.

Trail-accumulating scenes normalise against their own peak. For the attractors, absolute brightness depends on particle count, frame rate and canvas size at once, and any fixed gain that looks right in one terminal fills in solid in another. Normalising against a smoothed peak makes relative density the thing being drawn, which is where the structure is.

Braille's one bit per dot drives several scene decisions. A dense cloud either clears the dot threshold and fills solid or falls under it and vanishes, which is why the attractors use half-blocks and why hilbert caps its order by how many pixels a grid cell actually gets.

Dev tools

npm test          # node:test, no test dependency
node tools/snap.ts <sceneId> [--t s] [--cols n] [--rows n] [--mode m] [--frames n]
node tools/bench.ts [--cols n] [--rows n] [--frames n]
node tools/soak.ts
node tools/check-awake.ts

snap dumps one frame as plain text — how the geometry in here got debugged without a TTY. bench reports median and p95 render cost per scene against the frame budget. soak runs every scene in every mode at six terminal sizes, including deliberately awkward aspect ratios, and fails on a throw, a NaN, or a scene that drew nothing. check-awake is described under Verifying it yourself — run it per platform.

npm i pulls the devDependencies (typescript, @types/node) that npm run typecheck wants. The program itself still needs nothing.

Publishing

The npm package ships compiled JavaScript rather than the source, because Node refuses to strip types from anything under node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). A globally installed package lives exactly there, so shipping the .ts files would install fine and then fail on first run.

npm run build     # tsconfig.build.json -> dist/
npm pack          # inspect the tarball; publishing itself happens in CI

build emits with rewriteRelativeImportExtensions, which turns the ./foo.ts specifiers the source uses into ./foo.js. It is wired to prepack, so npm publish and npm pack both build first. None of this affects working in the repo, and dist/ is not committed.

A release is a tag, and nothing else:

npm version patch -m "chore: release v%s"    # or minor / major
git push --follow-tags

.github/workflows/publish.yml takes it from there: it refuses a tag that disagrees with package.json, runs typecheck and the tests, and publishes. There is no npm token in this repo or on anyone's machine — npm authenticates the workflow through GitHub's OIDC token, which expires, so there is nothing reusable left to steal. That matters more here than it might elsewhere: the package has no runtime dependencies, so a publishing credential was the entire supply chain.

The same exchange attaches a provenance attestation — the repository, commit and workflow that built the tarball, shown on the npm page and checkable by anyone:

npm audit signatures

This needs one setting on npmjs.com, once — Settings -> Trusted publishing -> GitHub Actions on the package, naming this repository, the workflow filename publish.yml (the filename alone, not a path), and npm publish as the allowed action. Until it exists, the workflow's first run fails at npm publish — which is the right way for that to be missing.

License

MIT. See LICENSE.