tui-saver
v0.4.3
Published
A terminal ASCII/braille screensaver that keeps the host awake
Maintainers
Readme
tui-saver

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 updateAn 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.tsBelow, 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 optionScenes 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 quitThe 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:
**fwhile it's running** — pins whatever is on screen and stops the clock. The status bar switches topinned/f:unpin. Press it again to resume cycling.nandpstill 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-9drive 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 itOnly 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 dismissesNothing 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
hor 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 PreventUserIdleSystemSleepLongest 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 16638caffeinate -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.tsStatic 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 assertionsshows aUserIsActiveassertion named forcaffeinate, and it disappears when the two-second timeout expires.Windows —
GetLastInputInfomoves. 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 1978953The 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.
Linux —
xdg-screensaver resetreports 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 lockColour 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 sceneA 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.tssnap 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 CIbuild 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 signaturesThis 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.
