Skip to content

Architecture

w0rxbend edited this page Aug 1, 2026 · 1 revision

πŸ—οΈ Architecture

A deliberately small Go program. Roughly 2,000 lines of implementation across five internal packages, each with one job.


πŸ“¦ Package map

Package Owns
cmd/nerd-fonts-installer Flags, command flow, exit codes.
internal/config Loading, defaults, normalization, validation, discovery.
internal/nerdfonts GitHub releases API client.
internal/fonts Download, verify, extract, atomically replace, refresh cache.
internal/fontname The single shared family-name validator.
internal/tui The Bubble Tea picker.

main() does almost nothing: it wires os values into a testable run(ctx, args, stdin, stdout, stderr, deps) int. Dependencies are injected as a struct of function-typed fields β€” loadConfig, discoverConfig, listReleases, runTUI, installFonts, isTerminal β€” rather than interfaces. For a CLI this size, function fields give the same test seams without the ceremony.


πŸ”„ The install pipeline

For each family, concurrently, up to four at a time:

resolve release
      ↓
fetch SHA-256.txt manifest        (best effort β€” a warning if absent)
      ↓
download <family>.zip             (size-capped)
      ↓
verify sha256 against manifest    (mismatch β†’ abort)
      ↓
extract .ttf/.otf/.ttc only       (size-capped, into a temp directory)
      ↓
move current family dir β†’ .old
move temp dir           β†’ destination
remove .old                       (best effort)
      ↓
fc-cache -f <destination>         (once, at the end, if enabled)

Concurrency is safe because each family writes to a disjoint path. That is the property to preserve if you ever touch the install loop. A synchronized writer keeps progress lines from interleaving mid-line.


πŸ›‘οΈ Security model

The tool downloads archives from the internet and writes files into your home directory, so the boundaries are explicit.

Family names are the trust boundary. Every name passes internal/fontname.Validate before it is joined onto a filesystem path or a URL: no separators, no .., no leading -, no empty names. There is exactly one implementation, used by both config and fonts β€” duplicating it is how these guards rot.

Downloads are verified. Each archive is checked against the release's SHA-256.txt. A mismatch aborts before anything is written. A missing manifest warns and proceeds.

Zip bombs are bounded.

Limit Value
One downloaded zip 768 MiB
One extracted font file 128 MiB
Total uncompressed per archive 2 GiB

Config decoding is strict. Unknown keys are rejected in both YAML and JSON, so a typo fails loudly instead of silently doing nothing.

No shell. fc-cache runs through exec.CommandContext with an argument list β€” there is no string interpolated into a shell.

URL segments are escaped. Release tags go through url.PathEscape before they are used in a download URL.

Writes are flushed and closed explicitly. A swallowed close error can promote a truncated font file, so both Sync and Close results are surfaced.


πŸ”’ Exit-code contract

Code Meaning
0 Success, or the user cancelled
1 Runtime failure β€” network, filesystem, extraction, checksum
2 Correctable input β€” missing config, unknown release, bad flag value

The mapping lives in one function (exitCodeFor) so it cannot drift between call sites.


🌐 The releases client

internal/nerdfonts.Client is a zero-value-with-defaults struct: leave it empty and it uses a 30-second HTTP client against the public API; set HTTPClient, BaseURL, or MaxPages in tests. Pagination stops on an empty raw API page rather than a filtered-empty one, so a page of releases without font assets does not truncate the list.


🎨 The TUI

Two steps β€” release, then families β€” sharing one Bubble Tea model, with a stepDone state that renders the confirmation before handing control back.

The layout is budgeted: the banner, separators, panel chrome, and help line have a known row cost, and the list gets whatever is left. That budget matters β€” a frame even one row taller than the terminal makes Bubble Tea truncate the top of the view, which silently eats the banner's top border. A test asserts the rendered height never exceeds the terminal height across a range of sizes.

The layout is responsive: the side panel is dropped when it will not fit beside the list, and the banner drops its subtitle and badges on short terminals.

Icon sets are data tables, not switch statements β€” adding a family glyph means editing a map.


πŸ§ͺ Testing approach

  • run takes explicit I/O, so the whole command is testable without touching os.
  • HTTP goes through an injectable *http.Client; tests use a roundTripFunc with in-memory zip archives, or httptest.
  • Table-driven tests with t.TempDir() for anything filesystem-shaped.
  • Everything must pass under -race β€” the install path is concurrent.

πŸ“Έ About the screenshots

The terminal screenshots in the README and on the website are generated from real runs, not mocked up. A harness runs the tool in a pty, captures the final screen with a terminal emulator, and renders it to SVG with box-drawing characters drawn as vectors. bwrap shadows $HOME so a genuine install lands in a throwaway directory while the paths on screen still look ordinary. See scripts/screenshots/.


Next: 🀝 Contributing

Clone this wiki locally