flagstaff

flagstaff

The staff the flag flies from. A terminal frame loop with a static projection for agents and screen readers, and a plugin host for spinners, progress, boxes and tables. Drop-in paths for ora, log-update, boxen and cli-table3. No dependency outside the burgee family.

ora animates a spinner and, off a terminal, prints frames anyway — \r after \r into the log an agent reads back. Ink fixes the terminal by shipping React and a layout engine. flagstaff is the staff the flag flies from: a frame loop that hoists a component, holds it, changes it and lowers it, and a static projection that is what every mode but the terminal gets — one line per state on a pipe, one event per transition under --json, plain text for a screen reader. Plugins are data. No layout engine. Four dependencies, all from this repository: roundel, paratext, linegauge and closeout.

A flagstaff is the simplest part of the whole apparatus and the only one that is always in view: a flag is hoisted on it, held there, changed, and lowered when it is done. That is a terminal render loop — the place frames are hoisted, held, changed and lowered, in view of whoever is reading.

Use

import { hoist } from 'flagstaff/loop';
import { spinner } from 'flagstaff/spinner';

const flag = hoist(spinner(), rt, { text: 'building' });
// ... work ...
flag.update({ text: 'linking' });
flag.lower({ text: 'built', status: 'ok' });

rt is a runtime — { env, isTTY: { stdout }, stdout, stderr, clock }; burgee's satisfies it, so does a literal in a test. Which of the five modes runs is decided once by roundel's output policy, never by this package:

modewhat the same three calls write
tty⠋ building repainted in place on the clock, then ✔ built left on screen
pipe, ci… building… linking✔ built — one line per state change, no \r, no escape
json{"event":"spinner","state":{"text":"building"}} … one NDJSON event per transition, on stderr
accessiblethe static text, never a redraw

What is here

The loop

hoist(component, rt, initial, { json }) returns { update(state), lower(state?), mode }. A component is { name, static(state), frame?(t, state), interval? }. static is required and is the artifact: what a pipe, an agent, a screen reader and the docs gallery read. frame is optional and decorative. Nothing in the loop reads process; time comes from rt.clock, so manualClock() makes a spinner's terminal output a fixed string a test can assert byte for byte.

The plugin host

A plugin is one plain object, validated against schema.json — the same file that ships in the tarball — and registered once:

// a third-party plugin, in full
export default {
  name: 'nyan',
  spinners: { nyan: { frames: ['≋', '≈', '~'], interval: 80, static: '…' } },
};
import { register } from 'flagstaff/plugin';
register(nyan);
spinner('nyan');

Keys: spinners, borders, glyphs (ok, fail, warn, info, running — change them and every built-in that draws one changes), tokens (a roundel theme), components. A spinner or component without a static is refused at register() with E_NO_STATIC_PROJECTION and a fix. The built-in dots and line styles are a plugin of exactly this shape, registered through the same door, so the built-ins cannot grow an API a plugin cannot reach.

register() is the only way in, and that is a property rather than a convention: registered() hands back a copy — new maps over the frozen objects register() stored — so registered().spinners.set(…) puts nothing in the registry and .clear() empties nothing. A contribution that never met validate() cannot be reached by spinner(), box() or the gallery, which is what makes "refused at the door" (U3) a fact about the code rather than advice. Freezing also means the object you registered stays yours: edit it afterwards and the registry does not change.

A component may declare sample: { running, done } — the two states flagstaff check and the docs gallery show it with. The loop never reads it; a running program's state comes from the program. Without one, both assume { phase: 'running' } / { phase: 'done' } and say so in the output, rather than rendering an invented state as if it were yours.

The built-ins

Five components, each on its own subpath, each answering the static projection for itself:

import { progress } from 'flagstaff/progress';
import { tasks } from 'flagstaff/tasks';
import { box, boxComponent } from 'flagstaff/box';
import { table, tableComponent } from 'flagstaff/table';
componenton a terminaleverywhere else
spinner⠹ building… building, then ✔ built
progressa bar of blocks12/30 files · 40%
tasksevery task, the running one animatedone line per task that has settled
boxthe border, padding and titletitle: text
tablethe gridone line per row of header: value pairs

That table is the package's argument in one place. A bar of in a log file tells an agent nothing and tells a screen reader less; the count and the percentage tell both.

box and table are also plain string functions, because most callers want the string:

box('Ready on :3000', { title: 'dev', width: 40 });
table([['ora', '99'], ['log-update', '99']], { head: ['host', 'tests'] });

No layout engine, and there will not be one: these measure with width(), wrap with wrap(), and join strings.

The ora path

flagstaff/ora is ora 9's whole API, graded 99 / 99 by ora's own test suite through compat-oracle. One import changes:

-import ora from 'ora';
+import ora from 'flagstaff/ora';

Everything else stays: ora({ text, spinner, color, indent, prefixText, suffixText }), .start() .stop() .succeed() .fail() .warn() .info() .stopAndPersist(), oraPromise(), the spinners corpus, the stream hooks that keep a console.log above the frame, the synchronized-output sequences, the render deferral, the stdin discarder.

What changes is the bill. ora 9.4.1 ships 113,577 B of JavaScript across seventeen packages — ora, chalk, cli-spinners, string-width, log-symbols, cli-cursor, restore-cursor, onetime, mimic-function, signal-exit, is-interactive, is-unicode-supported, stdin-discarder, yoctocolors, strip-ansi, ansi-regex, get-east-asian-width. flagstaff/ora is 55,641 B across two — itself and roundel — 49% of ora's, and 20,250 B of that is the spinner corpus ora's API re-exports. Nothing in it reaches the frame loop, so a program that migrates its spinner today can adopt hoist() a file at a time, or never.

Both sides are counted the same way, so the number reproduces: shipped code and data — .js/.mjs/.cjs plus the .json a module imports, package.json never counted. Ours is the import graph walked from dist/ by weight.test.ts; ora's is every package that graph touches in ora's own resolved tree, counted whole. Counting ora the stricter way — only the 27 files its graph actually reaches — gives 101,809 B, and flagstaff/ora is still 55% of that.

The cursor comes back the way ora's does. A spinner that hid the cursor restores it on a clean exit and on SIGINT, SIGTERM and SIGHUP — node does not run 'exit' listeners for a signalled process, and Ctrl+C is how a spinner usually dies — then re-raises the signal so the process still terminates, unless the program installed its own handler for it. That is what ora buys with restore-cursorsignal-exit; here it is twenty lines and no dependency. ora's own 99 never kill a process, so ora.test.ts grades it instead.

The repo's accessible switch does not reach this surface. CLI_ACCESSIBLE=1 changes what roundel/policy decides for every other entry point in the stack, but flagstaff/ora imports roundel/chalk and never the policy, because a façade that reinterpreted its host would fail the host's suite — so on a terminal CLI_ACCESSIBLE=1 still animates and still writes cursor escapes (measured: 4 escapes, cursor hidden), where CI=true disables the spinner outright (0 escapes) because that is ora's own rule. Off a terminal it is moot. Use hoist() when you want the switch to be honoured.

The static projection is the reason to move on eventually, not the reason to move: hoist() is what gives a pipe one line per state instead of frames. flagstaff/ora is the door, and it is deliberately ora's behaviour to the byte.

The boxen path

flagstaff/boxen is boxen 8's API, graded 84 / 84 by boxen's own test suite — every one of whose cases is a snapshot of the exact characters the box comes out as.

-import boxen from 'boxen';
+import boxen from 'flagstaff/boxen';

borderStyle (all eight of cli-boxes', a style object, or none), borderColor, backgroundColor, dimBorder, title and titleAlignment, textAlignment, padding, margin, width, height, float, fullscreen, and _borderStyles.

The drawing is the contract here, and matching it byte for byte is the compatibility claim rather than a way of avoiding one: a user leaving boxen cares about one thing, whether the box still looks the same. It carries cli-boxes' table itself rather than reading the plugin registry — a façade whose drawing changed when somebody registered a plugin would be reinterpreting its host. Named borders through the registry are flagstaff/box's job.

The cli-table3 path

flagstaff/cli-table3 is cli-table3 0.6.5's API, graded 29 / 29 by cli-table3's own test suite — the 38 of its cases that go through the public surface, less the nine that grade cli-table, the legacy incumbent, and so pass whatever the target is. The other 197 require('../src/…') and test its four internal modules directly; those are reported beside the number and never gate it, because passing them would mean copying cli-table3's file layout rather than matching its behaviour.

-const Table = require('cli-table3');
+import Table from 'flagstaff/cli-table3';

head, chars, style (padding, head, border, compact), colWidths, rowHeights, colAligns, rowAligns, truncate, wordWrap, wrapOnWordBoundary, per-cell colSpan, rowSpan, hAlign, vAlign, href, and the debug channel with table.messages and Table.reset(). It extends Array, because cli-table3 does and its callers push rows onto it.

Four dependencies folded into one module rather than four: cli-table3's table.js, layout-manager.js, cell.js and utils.js become one file, because the architecture is not the contract — the drawing is.

The log-update path

flagstaff/log-update is log-update 8's API, graded 99 / 99 by log-update's own test suite — which renders every frame through a real terminal emulator and asserts the screen, not the bytes.

-import logUpdate from 'log-update';
+import logUpdate from 'flagstaff/log-update';

logUpdate(), .clear(), .done(), .persist(), createLogUpdate(stream, options) and logUpdateStderr, with the row-level diffing intact: a five-row frame whose last row is a counter costs one row of output per tick, not five.

log-update ships 113.4 KB across sixteen packages. This subpath reaches two, both from this repository: linegauge/wrap for the wrapper, and closeout for the cursor. It carries no port of slice-ansi — the wrapper already makes every row self-contained, so clipping a frame to the terminal's height is an array slice. signal-exit, 22.0 KB of those sixteen, is closeout's to own, shared with the ora façade because both incumbents port the same cli-cursorrestore-cursorsignal-exit chain. Ctrl+C mid-frame puts your cursor back, and still terminates — unless your program installed its own SIGINT handler, in which case it is delivered once, to you, and this stays out of it.

This is the one façade that lowers a layer guarantee, and it says so. R5 — no cursor escape off a terminal — cannot survive here: log-update's own suite asserts erase sequences on a plain non-TTY stream, so a façade that suppressed them would fail the suite that is the whole claim. What survives is the half the complaint behind R5 was actually about: nothing this writes is ever a \r, and never an absolute cursor-home — every move is a relative row move — on a terminal or off one, so a captured transcript stays parseable. log-update.test.ts asserts both halves, and hoist() is what gives you the whole guarantee.

Bringing a corpus with you

The ecosystem already has ~80 spinner styles and eight border sets, as plain JSON. Neither is bundled here — the weight of a corpus nobody asked for is the thing this package exists to avoid — so flagstaff/import turns the one you have into an ordinary plugin:

import cliSpinners from 'cli-spinners';
import { fromCliSpinners } from 'flagstaff/import';
import { register } from 'flagstaff/plugin';

register(fromCliSpinners(cliSpinners));
spinner('moon');

fromCliBoxes(cliBoxes) does the same for borders, after which box('…', { border: 'arrow' }) draws with one. Both go through the same register() and the same schema, so the gallery opens full and a third-party plugin starts as a copy of one of these. The importer is 838 B and reaches nothing.

flagstaff check

npx flagstaff check ./nyan.mjs

Loads the file, validates it, and prints every contribution in all five modes side by side — escapes made visible — so an author, or an agent that just wrote one, sees the static projection next to the animation before anything ships. Exit 1 on a refusal, with the code and the fix.

It opens with a census of what it found and closes with the verdict, so ok is never printed before the rendering that would justify it:

nyan — 1 spinner, 0 borders, 0 components, 0 glyphs, 0 tokens
spinner nyan
  tty         ␛[?25l≋ working␛[1G␛[0J…
  pipe        ~nyan~ working⏎ ✔ done⏎

nyan: ok

0 spinners, 0 components is how a misspelled key tells on itself. The schema is additionalProperties: true on purpose — a key another package in the family reads belongs in the same object — so a typo cannot be refused by the schema, and check is the surface that has to notice:

typo — 0 spinners, 0 borders, 0 components, 0 glyphs, 0 tokens
  unknown     componets, spinner — flagstaff reads none of these; a key another package in the family reads is allowed here
E_NO_CONTRIBUTION: typo registers, but contributes nothing flagstaff can render
  fix: flagstaff reads spinners, borders, components, glyphs and tokens; check those spellings. …

Each component block names the state it was rendered with — the component's own sample when it declares one, and otherwise the assumed { phase } shape, said out loud. A static that throws on the state it is handed is a refusal like any other, naming the modes it broke in (json emits the state and never calls static, so it is usually the one that survives):

E_COMPONENT_THREW: g threw in tty, pipe, ci, accessible
  fix: `static(state)` must return a string for the state it is rendered with; …

Weight

Every subpath is a lock, not a convention, and the numbers below are asserted by weight.test.ts against dist/, not estimated: flagstaff/loop reaches 4.4 KB on disk and never the plugin registry; flagstaff/plugin 8.4 KB, of which 2.4 KB is the schema; flagstaff/spinner 9.4 KB; flagstaff/ora 46.5 KB — 55.9 KB with roundel counted, against ora's own 113.6 KB; flagstaff/log-update 29.6 KB, against log-update's own 113.4 KB across sixteen, reaching only linegauge/wrap and closeout; flagstaff/boxen 33.7 KB — 43.0 KB with roundel counted, against boxen's own 132.4 KB across nineteen; flagstaff/cli-table3 32.9 KB — 42.3 KB with roundel, against cli-table3's own 106.0 KB across seven. The three façades share wrap.js and width.js, and the first two share closeout; none reaches another's port, and none reaches the core. sideEffects: false lets a bundler drop what a program does not use. ESM with a default condition, so require('flagstaff/spinner') works from CommonJS on Node ≥ 24.

What is next

  • Drop-in paths for boxen and cli-table3, graded by their own suites through compat-oracle the way ora and log-update already are.

Every component, every registered plugin and every border is on the gallery, which is generated by running them — the static projection beside the animation, in all five modes.

Following along

The intent and design are committed before the code is, so you can read what it will be — and argue with it — before it exists:


Part of the burgee family: a CLI on burgee declares what it is, roundel carries its colours, flagstaff flies it, and caique answers back. Each is an independent package; none requires the others.

MIT © Ofri Peretz — see LICENSE.

Benchmarks

Every number here is produced by npm run bench and published at /docs/benchmarks.

Graded by the incumbent's own test suite:

suitepassing
boxen84 / 84
cli-table329 / 29
log-update99 / 99
ora99 / 99

Where it sits

Plugins register under the tokens, glyphs, spinners, borders, components keys, against the one schema the whole family shares.

Nothing in this family builds on it yet, and it builds on closeout, linegauge, paratext, roundel.

On this page