9.2 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 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)
-
Never add a top-level
preloadtobunfig.toml. A compiled PodTui binary's embedded runtime reads the launching process's CWDbunfig.toml, and apreloadentry points at a module the standalone can't resolve (@opentui/solid/preload) → the binary dies at startup withpreload not found. This is whybunfig.tomlhas no top-levelpreload; dev-mode preloading happens via explicit--preloadflags inpackage.json. The[test]section does keep a preload — that only affectsbun test. -
Smoke-test the compiled binary from a bunfig-free dir. Because of (1),
./dist/podtui --versionrun from the repo root launched inside CI would fail. CI always unpacks the tarball into amktempdir before booting. Do the same 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-podtuirepo 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/podtui/podtui. -
AUR packaging (
packaging/aur/PKGBUILD): thepodtui-binpackage is staged, not yet published (AUR account registrations are closed; see the README note in section 3). 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-podtui>
./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.
Open items / things to sort out
- LICENSE:
README.mdsays "TBD — choose and document a license before the first release". Pick one (MIT/BSD-3) and addLICENSE+ update the README footer. - 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.