One repo, one CLI, one command — cross-platform development environment.
A modular dotfiles manager that lets you set up your shell, editor, and CLI tools on any new machine with a single command. Pick a profile (minimal / standard / full) or install individual modules; uninstall anything you don't want; everything is logged and reversible.
- bash ≥ 4.0 (associative arrays for module dependency resolution). On macOS,
/bin/bashis still 3.2 — runbrew install bashonce;envupauto-detects Homebrew's bash when you./envup(your login shell / tmux shell can stay zsh). - git ≥ 2.0 (submodule rename support; almost certainly already installed)
- A POSIX system: macOS, Linux (Ubuntu/Debian/Fedora/CentOS/Arch/Alpine), WSL2, or Docker
- A package manager: apt / dnf / yum / pacman / brew / apk
- Network access for first-time install (downloads zsh plugins, optional curl-installed tools like atuin/fzf)
sudoavailable if any system packages are missing (you'll see the prompt)- Recommended:
~/.local/binon$PATHsoenvupis globally accessible after install
# Clone (with submodules — required for zsh themes/tmux plugins)
git clone --recursive https://github.com/gendu-amd/envup.git
cd envup
# Forgot --recursive? Catch up:
# git submodule update --init --recursive
# Install the standard profile (zsh, git, tmux, fzf, zoxide, atuin)
./envup install
# ... or pick a smaller profile
./envup install --profile minimal
# ... or install just what you need
./envup install zsh git
# Start a new shell
exec zsh./envup install [--profile NAME] [--dry-run] [MODULE...] # Install
./envup uninstall [--all] [--dry-run] MODULE... # Remove
./envup upgrade [--profile NAME] [--dry-run] [--keep-going] [MODULE...] # git pull + reinstall
./envup status # What's installed (✓ / ○)
./envup clean [--dry-run] [--all | MODULE...] # Clear caches (meta CLEAN_PATHS)
./envup log [--tail] # Most recent command's logUse ./envup <command> --help for command-specific options.
A few important semantics that aren't obvious from the one-liners:
install --profile X MODULE...is a UNION, not OR —envup install --profile minimal nviminstalls minimal's modules and nvim, deduped.upgradeby default only reinstalls modules already in your manifest (~/.local/state/envup/installed). If your team added a new module to a profile, pass--profile NAMEto pick it up.upgrade --keep-goinglets the run continue even ifgit pullfailed; otherwise upgrade aborts to avoid silently reinstalling stale config.upgrade --dry-runskipsgit pullentirely and forwards--dry-runto install.cleanremoves module-managed plugin caches (lazy.nvim, mason, oh-my-zsh, etc.) — NOT the binary, NOT your config. Useful when nvim Lazy state gets weird.logshows the most recent command's log (install, uninstall, upgrade, or clean — whichever ran last).
| Profile | Modules | Use case |
|---|---|---|
minimal |
zsh git |
Bare server, headless container |
standard (default) |
+ tmux fzf zoxide atuin |
Typical developer workstation |
full |
+ nvim |
Power-user workstation |
Profiles are just bash files at profiles/ — easy to read, easy to add your own:
# profiles/myown.sh
MODULES=(zsh git tmux atuin)Then ./envup install --profile myown.
Each profile lists its modules explicitly — no inheritance, no indirection, so what you get is exactly what you read:
# profiles/minimal.sh
MODULES=(zsh git)
# profiles/standard.sh (default)
MODULES=(zsh git tmux fzf zoxide atuin)
# profiles/full.sh
MODULES=(zsh git tmux fzf zoxide atuin nvim)Want a bigger set? Either copy a line and edit it, or union on the CLI:
./envup install --profile standard nvim.
Each module is a self-contained directory under modules/ with three files:
modules/<name>/
├── meta.sh # Declares NAME, DESCRIPTION, DEPENDS=(...)
├── install.sh # Hook: install package + symlink configs
├── uninstall.sh # Hook: remove envup-managed symlinks
└── files/ # Config files (symlinked to ~/)
Adding a new tool = creating a new directory. No registry, no config update.
| Module | Tool | Depends |
|---|---|---|
zsh |
Modern shell with Oh-My-Zsh + Powerlevel10k (also makes zsh your default shell) | — |
git |
Git config (with delta as pager) | — |
tmux |
Terminal multiplexer (new panes use zsh) | — |
fzf |
Fuzzy finder (Ctrl+T / Ctrl+R) | — |
zoxide |
Smarter cd — z <dir> to jump, zi to pick |
zsh |
atuin |
SQLite-backed shell history | zsh |
nvim |
Neovim with NvChad (plugins pinned via lazy-lock.json) | — |
The zsh module makes zsh the shell you actually land in, on three fronts:
chshchanges your login shell (effective next login).- On accounts where
chshis blocked (LDAP/SSSD-managed corp/HPC boxes), a small guarded block is added to~/.bashrcthatexecs zsh for interactive bash. Escape hatch:NO_ZSH=1 bash. - The
tmuxmodule setsdefault-command zsh, so new panes use zsh regardless of the system login shell.
envup uninstall zsh removes the ~/.bashrc block (it leaves the chsh
setting alone).
The nvim module symlinks the NvChad config to ~/.config/nvim and installs
plugins. NvChad needs nvim >= 0.10; if your distro's nvim is older, the hook
stops and prints upgrade options (envup never touches your system package
sources):
brew install neovim # macOS
conda install -c conda-forge neovim # old-glibc systems (RHEL/CentOS 8, …)
# or build from source: https://github.com/neovim/neovim/blob/master/BUILD.mdReproducible plugins. The plugin set is pinned by a committed
lazy-lock.json, validated to load on both nvim 0.10 (old-glibc hosts) and
0.11 (containers). envup install nvim restores exactly those versions, so
every machine gets the same editor. Control it with ENVUP_NVIM_LAZY:
restore(default) — install the pinned versions fromlazy-lock.json.sync— update plugins to latest within spec and rewrite the lock; commit the newlazy-lock.jsonafterwards to roll it out everywhere.skip— leave plugins for nvim's first interactive launch.
./envup clean nvim clears plugin/cache state if it gets stuck; the next
install restores from the lock.
┌─────────────────────────────────────────────────────┐
│ ./envup install --profile standard │
│ ↓ │
│ load profiles/standard.sh → MODULES=(zsh git ...) │
│ ↓ │
│ resolve order (each module's DEPENDS first) │
│ ↓ │
│ for each module: │
│ source modules/<m>/install.sh │
│ safe_link <repo files> → ~/ │
│ record in ~/.local/state/envup/installed │
│ ↓ │
│ log to ~/.local/state/envup/logs/install_<ts>.log │
└─────────────────────────────────────────────────────┘
Key properties:
- Idempotent: Re-running
./envup installis safe. Existing symlinks are detected and skipped. - Reversible: Every overwritten file is backed up to
~/.dotfiles_backup/<timestamp>/../envup uninstallremoves only envup-managed symlinks. - Loggable: Every command writes a timestamped log under
~/.local/state/envup/logs/. Use./envup log --tailto follow live. - Cross-platform: macOS, Linux (apt/dnf/yum/pacman/brew/apk), WSL2, Docker. Auto-detects the platform and package manager.
envup recognises these env vars at install time. All are optional; defaults are sensible for the common case.
| Variable | Default | Effect |
|---|---|---|
ENVUP_DRY_RUN |
0 |
When 1, every destructive step prints what it would do without changing anything. --dry-run sets this automatically. |
ENVUP_NVIM_LAZY |
restore |
restore installs the pinned versions from lazy-lock.json; sync updates to latest and rewrites the lock; skip leaves them for nvim's first launch. |
ENVUP_ATUIN_INSTALL |
— | Set to skip to skip the atuin module (handy when its installer is blocked by a network/proxy). |
ENVUP_NET_TIMEOUT |
120 |
Per-command timeout for git operations. Falls back gracefully when timeout(1) is unavailable (macOS: brew install coreutils for gtimeout). |
ENVUP_NET_TIMEOUT_NVIM |
600 |
Larger timeout for nvim --headless +Lazy! (cloning 30+ plugins takes minutes). |
ENVUP_NET_TIMEOUT_INSTALLER |
300 |
Timeout for curl ... | sh installers (Oh-My-Zsh, atuin, zoxide). |
ENVUP_NET_KILL_AFTER |
10 |
Grace period (s) after a network timeout before the process is SIGKILLed, so a wedged connection can't hang past the budget. |
Docker example:
docker run -it --rm ubuntu:24.04 bash -c '
apt-get update && apt-get install -y git ca-certificates &&
git clone --recursive https://github.com/gendu-amd/envup.git /opt/envup &&
/opt/envup/envup install --profile standard
'If a link target is an existing real file (e.g. a ~/.zshrc you wrote by
hand), envup always backs it up to ~/.dotfiles_backup/<timestamp>/ before
creating the symlink — it never silently overwrites your files. To restore one,
move it back from that directory.
./envup log # show the latest log (install/uninstall/upgrade/clean)
./envup log --tail # follow live (useful for long installs)
# Logs persist at:
ls ~/.local/state/envup/logs/If something fails:
- Check the log — every command's exit code, duration, and any stderr is captured.
- Re-run with
--dry-runto see what would happen without doing anything. - The hook script for the failing module is at
modules/<name>/install.sh— read it, edit it locally, retry.
./envup install fails on a minimal docker image — envup installs each selected module's declared SELF_DEPS (e.g. curl, git) up front. If that fails (e.g. apt repos blocked), install those packages manually then re-run.
zsh prompt is plain / Powerlevel10k missing — you probably forgot --recursive when cloning. Fix:
git submodule update --init --recursive
./envup install zshbash: warning: setlocale: cannot change locale (en_US.UTF-8) — envup runs locale-gen on apt systems automatically; if you see it persist, run sudo locale-gen en_US.UTF-8 manually.
envup: command not found after install — the zsh module symlinks envup to ~/.local/bin/envup. Make sure ~/.local/bin is on $PATH (login again, or exec zsh).
nvim too old error — NvChad needs nvim >= 0.10 and envup does NOT touch your APT sources. Upgrade via brew install neovim, conda install -c conda-forge neovim (best on old-glibc systems like RHEL/CentOS 8), or a source build, then re-run envup install nvim.
nvim Lazy plugins corrupt / want a clean state — ./envup clean nvim clears the plugin cache and Mason LSP servers without touching your config; the next ./envup install nvim restores the pinned plugin set from lazy-lock.json.
envup install / upgrade hangs forever on a slow/blocked network — every network operation (git pull, git clone, submodule update, nvim Lazy) is wrapped with a per-command timeout (default 120s for git, 600s for Lazy). On hit you'll see a TIMED OUT after Ns error and a hint to raise ENVUP_NET_TIMEOUT=... / ENVUP_NET_TIMEOUT_NVIM=.... Behind a slow proxy / VPN? ENVUP_NET_TIMEOUT=300 ./envup upgrade.
timeout warning on macOS — log line no 'timeout' command on this system means your install isn't protected against hung git/curl. Install GNU coreutils: brew install coreutils provides gtimeout, which envup auto-detects.
Configs are symlinks, not copies. Editing ~/.zshrc actually edits modules/zsh/files/.zshrc in this repo.
# Make a change on machine A
vim ~/.zshrc # edits the repo file
git add . && git commit -m "..."
git push
# Pull on machine B
git pull # changes apply instantly (no reinstall)
source ~/.zshrcFor machine-specific settings that should NOT be synced, use the local-overrides file:
cp modules/zsh/files/.zshrc.d/local.zsh.example ~/.zshrc.d/local.zsh
# Edit freely — gitignored, won't sync| Platform | Tested |
|---|---|
| macOS (Apple Silicon / Intel) | ✓ |
| Ubuntu / Debian | ✓ |
| Fedora / CentOS | ✓ (best-effort) |
| Arch Linux | ✓ (best-effort) |
| Alpine | best-effort |
| WSL2 | ✓ |
| Docker | ✓ |
- docs/ARCHITECTURE.md — design, layers, trade-offs
- CONTRIBUTING.md — adding modules / profiles, code style, testing
MIT — see LICENSE