Set autoloadBunfig: false in build.ts so the compiled runtime ignores any bunfig.toml in the launching directory, preventing startup failures from a CWD preload the standalone cannot resolve. Update release.yml, Makefile, bunfig.toml, CONTRIBUTING.md, and README.md to match.
10 KiB
Contributing to PodTui
This file is written for humans. If you're an AI agent or LLM working in this repo, read AGENTS.md instead — it has the machine-oriented build/test/lint contract and code-style rules. Both describe the same project; CONTRIBUTING.md focuses on understanding and navigating the codebase.
PodTui is a keyboard-first, yazi-style terminal podcast client. TypeScript + OpenTUI on top, Bun as the runtime and toolchain.
Quick start
brew install bun # or: curl -fsSL https://bun.sh/install | bash
git clone git@github.com:mikefreno/podtui.git
cd podtui
bun install # install JS dependencies
make native # build libcavacore.dylib from the vendored C source
bun run dev # launch with hot reload (alias: make dev)
The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
WezTerm, tmux, …). It will not render in a plain captured bash session.
What each command does
| Command | Purpose |
|---|---|
bun install |
Install JS dependencies |
make native |
Compile cava/cavacore.c → src/native/libcavacore.<dylib|so> |
bun run dev |
Run with hot reload |
bun run start |
Run once (no watch) |
bun test |
Run the test suite (see Testing) |
bun run lint |
Type-check |
bun run build |
Bundle JS into dist/ + copy native libs (the podtui npm script path) |
make dist |
Compile the standalone binary + make the current platform's tarball |
make dist-mac / make dist-linux |
Aliases for dist on their platform (CI runs these) |
make clean |
Remove dist/ |
Repository layout
src/
api/ Network + XML/RSS — client.ts, rss-parser.ts
components/ Reusable UI pieces: Shell, Navigation, YaziPaneRow, TabPanel…
config/ App config: keybinds.jsonc, shortcuts, auth
constants/ Static tables (sync formats, themes)
context/ Solid contexts: KeybindContext, NavigationContext, ThemeContext
hooks/ useAudio, useMultimediaKeys, useCachedData
native/ FFI glue + the built libcavacore.{dylib,so}
pages/ App screens: Feed, MyShows, Discover, Search, Player, Settings
stores/ Zustand stores — app, feed, audio-nav, search, auth, progress…
styles/ theme.css
themes/ catppuccin, gruvbox, nord, tokyo schemes + schema.json
types/ All shared interfaces (podcast, episode, feed, settings…)
ui/ Modal-adjacent UI: command.tsx, dialog.tsx, toast.tsx
utils/ Parser/persistence/audio helpers (audio-player, config-dir…)
scripts/
build-cavacore.sh C → shared lib; finds libfftw3.a on macOS & Debian
tui-harness.tsx Headless harness for scripted interaction (see below)
cava/ Vendored cavacore C source (MIT, from karlstav/cava)
tests/ bun test suite + cavacore smoke test
dist/ Build output (JS bundle + libs + tarballs)
Native libraries: how the FFI layer works
PodTui loads two native libraries at runtime:
- libopentui — the OpenTUI renderer (shipped inside the
@opentui/core-<platform>-<arch>npm packages, copied todist/bybuild.ts). - libcavacore — the audio spectrum renderer, built from C. The source is
vendored under
cava/(it must stay committed — every CI runner builds it).libfftw3is needed to build it:- macOS:
brew install fftw - Debian/Ubuntu:
apt-get install libfftw3-dev(CI installs it for you; locally runmake native.)
- macOS:
Critical sibling rule: both libraries are loaded relative to the binary,
so podtui, libopentui.* and libcavacore.* must sit in the same
directory. Never move a single binary out of the tarball. The Homebrew
formula keeps all three in libexec/ and exposes only a podtui symlink.
Cavacore smoke test: bun tests/cavacore-smoke.ts
(FFI-calls cava_init / cava_execute / cava_destroy and prints results).
Gotchas (read before touching anything)
-
The compiled binary must keep bunfig autoload disabled.
build.tscompiles the standalone withautoloadBunfig: false, so its embedded runtime never reads the launching CWD'sbunfig.toml. Without that flag, a top-levelpreloadin the CWD bunfig (common in Bun project dirs) resolves against the CWD rather than the binary and kills startup withpreload not found. Don't remove the flag. Preloads for dev/test belong in the explicit--preloadflags inpackage.jsonand the[test]section ofbunfig.toml— not as a top-level entry. -
Smoke-test the binary from a dir with a poisoned bunfig. The CI smoke test unpacks the tarball into a
mktempdir, drops abunfig.tomlcontaining an unresolvable top-levelpreloadnext to it, and boots the binary — proving bunfig autoload stayed disabled../dist/ podtui --versionmust work from any directory, including the repo root; do the same check when testing a release build locally. -
Homebrew's dylib-repair warning is benign.
brew installmay print “load commands do not fit in the header … needs-headerpad” for a prebuilt dylib. The app dlopens the libs by path, so the warning is cosmetic; installs complete and the app boots.
Testing
bun test # full suite (54 tests across 6 files today)
The suite covers the keyboard/nav model, keybind dispatch, and the yazi pane
logic; plus tests/cavacore-smoke.ts asserting the native lib exports.
For scripted end-to-end interaction there's a headless harness,
scripts/tui-harness.tsx: each invocation snapshot-rebuilds the app state
into a sandboxed .harness/ config dir, replays the saved action log
(.harness/actions.json), executes one more key/action passed on the CLI, and
prints the resulting frame + a style summary — all without a real terminal.
Audio is a no-op during those snapshots. The last frame lands in
.harness/last-frame.{json,txt} for inspection.
Releasing
Releases are built and published from tags
Steps
-
Run
scripts/release-tag.sh(interactive: pick major/minor/patch/custom, confirms the plan, bumpsVERSIONinsrc/index.tsx, commits, tagsvX.Y.Z, and pushes branch + tag to every remote). If the version bump is already committed but the tag is missing, it offers a tag-only path.--dry-runprints the plan without doing anything. -
Equivalent manual commands:
git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0 -
CI (
.github/workflows/release.yml) runs four builds in parallel, each producingpodtui-<platform>-<arch>.tar.gz:Runner Platform/Arch ubuntu-latestlinux-x64 ubuntu-24.04-armlinux-arm64 macos-15-inteldarwin-x64 macos-14darwin-arm64 Each runner: installs deps → installs fftw →
scripts/build-cavacore.sh→make dist→ smoke-boots the binary from a temp dir → uploads the tarball. (macos-15-intelmatters: GitHub'smacos-latestis arm64 now.) -
A release is auto-created with all 4 tarballs attached.
brewnever sees the new version: the tap self-updates: themikefreno/homebrew-taprepo has a scheduled workflow (hourly) that polls GitHub releases, and when a new tag appears, rewritesFormula/podtui.rb(URLs + arm64/x64sha256) and pushes it — no secrets. Seescripts/sync-formula.shin that repo for the logic. Local test:brew install mikefreno/tap/podtui. -
AUR packaging (
packaging/aur/PKGBUILD): thepodtui-binpackage is staged, not yet published (AUR account registrations are closed; see the README's Installation section). On each release, keep the AUR sources in sync with the new tag: bumppkgver, recompute the two tarballsha256sumsentries, keep theLICENSEasset source (the workflow above uploadsLICENSEto every release), and regeneratepackaging/aur/.SRCINFOwithbash packaging/aur/gen-srcinfo.sh.
Manual fallback
If you ever need to sync the tap by hand (or before the hourly job runs):
cd <clone of mikefreno/homebrew-tap>
./scripts/sync-formula.sh 0.2.0
git commit -am 'podtui 0.2.0' && git push
Local release build
make dist # builds the binary + tarball for THIS machine only
Bun cannot cross-compile — the other platforms come from CI.
Distribution & packaging
A release tarball is three files sitting side by side: the podtui binary
plus its two FFI libraries (libopentui.<dylib|so>,
libcavacore.<dylib|so>). The sibling rule above is why they ship together.
PodTui deliberately ships no .deb, .rpm, Flatpak, or Snap packages:
for a terminal app that's overwhelmingly installed through repositories or
archives, those formats add desktop-sandboxing overhead and a packaging tax
with little benefit. Instead:
- GitHub Release tarballs are the universal path — one upload per
OS/arch, works on any distro with
curl+tar. - AUR (
podtui-bin) covers Arch/Manjaro with the same binary through the native package manager. - Nix / cross-distro users build from source (or a Nix flake can be added later).
This keeps maintenance to a single build per OS/arch while still reaching the
vast majority of desktop Linux users. The AUR PKGBUILD lives in
packaging/aur/ and can be built locally to test before publication:
cd packaging/aur && makepkg -si
Open items / things to sort out
- Native libs in
dist/still need committing? No — they're built from sources kept in the repo (cava/,node_modules/@opentui/core-*). Onlysrc/native/libcavacore.dylibis a committed binary artifact; macOS arm64 ships from it directly until a full rebuild replaces it. On other hosts themake nativebuild is required — seescripts/build-cavacore.sh.