The problem
Every time I reinstall Linux, whether it's a new laptop, a fresh VM or a setup I've managed to break, I lose a whole day to the same routine. I add the same repositories, install the same packages, set up the same shell, re-enable the same services and try to remember which fonts, plugins and tweaks I had last time.
The existing options never quite fitted. Configuration management tools like Ansible are built for fleets of servers, not one person's laptop. A personal shell script is quick to write, but it breaks the moment a package name changes or I hop to a different distro. On Windows, Chris Titus's WinUtil nails the "tick what you want, click install" experience. I wanted that for Linux, and I wanted it to work on whichever distro I happened to be on.
So I built DistroForge.
Where it started: version 1.0
The first version was a Python CLI built on Rich. It ran seven hard-coded phases: system, shell, dev, gaming, apps, quality of life and verify. Its behaviour came from a single config.yaml. It did the job for me on Pop!_OS, and it was a great way to work out what a fresh install really needs.
It also had the problems you'd expect from a tool that grew out of my own setup:
- My choices were baked in. The default config had my name, email and hostname in it, along with my apps and my languages. Anyone else had to edit YAML before they could use it.
- It was really an Ubuntu tool. Package names like
build-essential, golang-go and software-properties-common were hard-coded, so a Fedora run fell over half-way through. Every phase was full of if ubuntu … elif fedora branches.
- It built shell strings. Commands were f-strings run with
shell=True, with config values interpolated straight in. A crafted config file could run anything. It also piped installers straight from curl into sh, and staged a GPG key at a predictable path in /tmp.
- Small bugs that mattered. Dry-run steps were counted as passed, so the summary lied. The pyenv setup only ever added its first line to
.zshrc. dnf check-update returns exit code 100 when updates exist, and that was reported as a failure. The version number lived in three places.
None of that is unusual for a v1. I just didn't want to keep building on top of it.
What I wanted from version 2.0
I set myself a few non-negotiables before writing any code:
- A real application, not a script. It should open full-screen in the terminal like btop, show up in the app menu with its own icon, and support both keyboard and mouse.
- Nothing pre-decided. The user picks every app and every tweak, and the install method for each one: distro package, Flatpak, AUR or the upstream installer.
- Distro-agnostic. Debian/Ubuntu, Fedora/RHEL and Arch from day one, with derivatives such as Mint, Pop!_OS, Nobara, Manjaro, EndeavourOS and CachyOS detected automatically. Flatpak covers everything else.
- Safe enough that I'd hand it to a friend. No shell strings, strict validation everywhere, root only where it's really needed, and an exact preview of every command before anything runs.
- Proper engineering: tests at every layer, strict typing, and real installs on real distros in CI.
What DistroForge is now

DistroForge 2.0 is a full-screen terminal UI built with Textual. On the left is a panel showing your system (distro, family, kernel, CPU, GPU, RAM, desktop) and a category sidebar. In the middle is a searchable catalogue of 162 apps and tweaks. Underneath is a details pane showing every way that item can be installed on your machine.
The flow is simple:
- Browse or search, then select. Press Space to pick something. Press m to cycle its install method, for example VLC from
dnf or from Flathub.
- Review. Press r to see the full plan: every step and the exact command it'll run, in order. Remote installer scripts, AUR packages and third-party repositories are flagged, and you have to acknowledge them before a live run.
- Run. Live progress, a status for each item and streaming output. If something fails you choose to retry, skip or abort.
It also includes:
- Profiles (Essentials, Developer, Gaming, Creator, Security baseline, plus "Classic", which reproduces everything 1.0 did) as starting points rather than presets. You can save your own and copy them to your next machine.
- Themes: all four Catppuccin flavours, plus my own Forge theme, Tokyo Night, Nord, Gruvbox, Dracula, Rosé Pine and more. Press t to cycle through them.
- Extensibility through YAML. Drop a file into
~/.config/distroforge/catalog.d/ to add your own apps, override built-in ones or hide anything you don't want.
- A scriptable CLI.
distroforge apply --profile developer --dry-run, distroforge doctor, distroforge list --ids and friends, so the same engine works in scripts and CI.
- Desktop integration. An app-menu entry that opens in your terminal, an SVG icon, and bash, zsh and fish completions.
Installing it takes one line, needs no root, and gets out of your way:
bash <(curl -fsSL https://raw.githubusercontent.com/RezaLabsHQ/DistroForge/main/install.sh)
How it works
The big change from 1.0 is that what to install is now data, and how to install it is a small engine.
catalogue (YAML) ─► Resolver ─► Planner ─► Plan ─► Runner ─► Executor
built-in + method per dependency every retry/skip/ argv only,
your catalog.d item, is it levels, command abort, sudo -n,
installed? batching shown dry run streaming
- Catalogue. Every item lists each way it can be installed (
apt, dnf, pacman, aur, flatpak or script) plus optional actions that run before or after the install. Every value is validated strictly when the file loads.
- Actions. Small, idempotent Python building blocks: add a signed apt or dnf repository, enable a systemd unit, add yourself to a group, set a persistent
sysctl, load a kernel module, configure the firewall, install a Nerd Font, generate an SSH key, or add a shell-init snippet. Each one can tell whether it's already applied, so re-running only does what's missing.
- Planner. Resolves dependencies (with cycle detection) into levels. Within each level it adds repositories first, then runs one transaction per package manager, then Flatpaks, then upstream installers, then configuration. Anything already installed or unsupported is skipped and doesn't drag in its dependencies.
- Runner. Executes the plan and emits events for the UI. If a batched install fails, it retries item by item so one bad package doesn't take its neighbours down. Anything that depends on a failed item is marked blocked rather than attempted.
- Executor. Runs argument vectors only, never a shell. Output streams line by line, and every step has a timeout and can be cancelled.
Adding a new distro family is one backend class. Adding an app is about five lines of YAML.
Security model
This was the part I cared about most, because a setup tool runs with a lot of trust.
- No shell, ever. Every command is an argv tuple. A package name like
; rm -rf ~ isn't dangerous, just invalid. A test fails the build if shell=True, os.system or os.popen appears anywhere in the codebase.
- No option injection. Every identifier (package, Flatpak ID, systemd unit, group, kernel module, font) has to match a strict allow-list pattern that can't start with
-.
- Root only where needed. DistroForge refuses to run as root. It suspends the UI, asks
sudo for your password once on the real terminal, keeps the credentials fresh while the plan runs, and runs every root step as sudo -n, so nothing can ever hang on a hidden prompt. The app never sees your password.
- Confined root writes. Files are only ever written as root inside a short allow-list of directories (
/etc/apt/keyrings, /etc/yum.repos.d, /etc/sysctl.d and so on). They're staged in a private temp directory and moved into place with install(1).
- No
curl | sh. HTTPS-only downloads, including redirects, with size limits and optional SHA-256 pinning. Upstream installers are downloaded to a private directory, run as you rather than root, and always flagged for review.
- Your dotfiles stay yours. Shell config changes go inside named, removable blocks, and edits are atomic and keep the file's permissions. Running it twice never duplicates anything.
Tech stack
- Python 3.10–3.14 in a
src/ layout, packaged with Hatchling
- Textual for the TUI and Rich for CLI output
- PyYAML (
safe_load only) for the catalogue, profiles and settings
- pytest with pytest-asyncio and Textual's Pilot for driving the UI headlessly
- Ruff for linting and formatting, mypy in strict mode, ShellCheck for the installer scripts
- Podman / Docker for real-install integration tests
- GitHub Actions: lint and types, tests on five Python versions, a wheel build and smoke test, and real installs inside Ubuntu, Debian, Fedora and Arch containers
Testing it properly
I didn't want "it works on my machine" to be the bar, so testing has several layers:
- 327 unit and UI tests at 89% coverage. They include tests that throw injection payloads at every validator, a test that plans the entire catalogue on every distro family to prove it's valid and cycle-free, and Pilot-driven UI tests that walk through search, select, review and dry-run in every theme.
- A real terminal. Headless screenshots don't always match a real terminal, so I also ran the app inside a pseudo-terminal with a terminal emulator library and read the screen back exactly as a user would see it.
- Real installs in real distros. For each of Ubuntu 24.04, Debian 12, Fedora 44 and Arch, CI starts a fresh container, creates a normal sudo user, installs DistroForge and then really installs packages. It checks that shell blocks are written, that a second run is a complete no-op, that root files land with the right owner and permissions, that a user catalogue extension works, and that the menu entry is created.
Problems I hit along the way
A few of these were humbling, and most were caught by the tests rather than by me.
- Placeholder substitution ate shell syntax. Catalogue values support placeholders like
{home} and {codename}. My first version used Python's str.format, which choked on the Oh My Zsh init line (( ${+functions[omz]} )). I now substitute only the six known placeholder names and leave everything else alone.
- One theme crashed the app. Looping the UI tests over every theme found that Textual's built-in
textual-dark leaves its background colour undefined, and my top bar tried to use it. I switched to Textual's resolved theme variables, which always hold a real colour.
- I accidentally overrode Textual internals. Strict mypy flagged that my
App._ready flag clobbered a method Textual itself relies on, and that my action_toggle shadowed a built-in action. Both would have caused confusing bugs later.
- Screenshots that lied. Exported SVG screenshots looked like they were dropping spaces. Before "fixing" anything I rendered the app in a real pseudo-terminal. It was perfect, and the problem was only the SVG-to-PNG conversion.
- SELinux and parallel containers. Running four distro containers at once with a private
:Z volume label meant only the last one could read the source. The fix was the shared :z label.
- Machine-readable output. Piping
distroforge list into a script wrapped long names, so "Python build dependencies" turned into a stray dependencies item. That's why list --ids exists.
- Minimal images are really minimal. Debian and Fedora container images don't ship
sysctl. The persistent setting is what matters, so applying it immediately is now best-effort and it simply takes effect at the next boot.
- My own validation broke SSH key generation. I rejected empty argv elements as a safety measure, which also rejected the empty passphrase that
ssh-keygen -N "" needs. Empty arguments are now fine; an empty program still isn't.
- AUR helpers call sudo behind your back.
paru and yay run as your user but invoke sudo themselves. A plan with only AUR items skipped authentication, and the helper's password prompt would have been hidden behind the UI. AUR steps now count as needing credentials.
- Symlinked dotfiles. An atomic rename on a
~/.zshrc managed by stow or chezmoi would have replaced the symlink with a plain file and silently detached someone's dotfiles repo. Writes now follow the link.
- The bug only CI found. Some package post-install scripts start a daemon that keeps the output pipe open. I made completion depend on the process exiting rather than the pipe closing, and every test passed on my machine (Python 3.14). On GitHub's Python 3.11 and 3.12 runners it timed out, because before 3.13, asyncio's also waits for the pipes to close. I rewrote that part using with a reader thread, confirmed it on 3.10 to 3.14 locally, and only published once CI was green across the board.
Design decisions I'm happy with
- Review before run. Seeing the exact
dnf install … line before it happens builds far more trust than any amount of documentation.
- Profiles as starting points. Loading "Developer" and then removing the three things you don't want is much nicer than editing YAML.
- Flatpak installs per user by default. Most apps don't need root at all.
- Partial installs finish the job. If Docker's packages are already installed but the service isn't enabled, DistroForge only does the missing part.
- One engine, two front-ends. The TUI and the CLI share the same bootstrap and planner, so they can't drift apart.
Roadmap
DistroForge is open source under the MIT licence. If there's an app you'd like in the catalogue or a distro you'd like supported, the repo is the best place: there's an app-request issue form ready to go, and PRs are very welcome.