Idempotent, environment-aware machine setup for ephemeral dev VMs and real
dev machines. Peer repo to dotfiles —
this repo installs tools; the dotfiles repo manages config via its
existing bare-repo (dot) workflow, which this repo invokes at the end.
Re-running is fast and idempotent: by default it installs only what's
missing and skips everything already present. To bump already-installed tools to
their latest versions, run the upgrade path (update-env.sh --upgrade).
On a fresh machine:
-
Set your git identity for the dotfiles bare repo via environment variables — no need to edit or fork this repo:
export DOTFILES_USER_NAME="Your Name" export DOTFILES_USER_EMAIL="you@example.com"
(Optional:
export DOTFILES_REPO_URL=...to use your own dotfiles fork.) These are read at run time; if unset, the fallbacks ingroup_vars/all.ymlare used. -
Run the bootstrap in the same shell (exported vars carry through):
# remote one-liner: curl -fsSL https://raw.githubusercontent.com/jsco2t/devbox-provision/main/bootstrap.sh | bash # or from a local clone: git clone https://github.com/jsco2t/devbox-provision.git && cd devbox-provision ./update-env.sh
bootstrap.sh ensures git is present, asks where to clone the repo (default:
<current dir>/devbox-provision), clones it, then hands off to the repo's
update-env.sh. That installs Ansible and converges everything: native
packages, Homebrew tools, Go/Rust toolchains, the Helix language tooling, and
finally your dotfiles. It also drops a ~/update-env.sh wrapper so you can
re-converge any time with a single command.
See Usage for re-runs, upgrades, dry runs, and details.
- OS: Linux and macOS (no Windows)
- Linux families: Debian-based and Enterprise Linux (RedHat family)
- Arch: arm64 and x86_64 (Homebrew, the native package managers, rustup, and the language installers all resolve arch themselves)
bootstrap.sh clones this repo locally; update-env.sh then installs Ansible
and runs local.yml against localhost (connection: local) — the same path
CI exercises. Roles run in order:
- common — print environment summary, ensure git / Xcode CLT
- native_packages — low-level utils from
apt/dnf(enables EPEL on EL) - homebrew — install Homebrew (Linux + Mac), then fast-moving tools +
the
node/uvtoolchains - golang — install Go via Homebrew
- rust — install Rust via
rustup+ therust-analyzercomponent - lang_tools — Helix editor LSPs/formatters/linters via their native
installers (
go install,cargo install,npm i -g,uv tool install) - dotfiles — reproduce the bare-repo
dotworkflow idempotently
Environment dispatch uses Ansible facts, not hand-rolled detection:
| Fact | Drives |
|---|---|
ansible_system (Linux/Darwin) |
brew prefix, native-vs-brew split |
ansible_os_family (Debian/RedHat) |
apt vs dnf, EPEL |
ansible_architecture |
Homebrew prefix (/opt/homebrew vs /usr/local) |
- Native (
apt/dnf): low-level utils that rarely change —jq,fzf,ripgrep,fd,vim,tmux, git, build tooling. Installedstate: presentby default (state: latestunder--upgrade). Seeroles/native_packages/vars/main.yml. - Homebrew (Linux + Mac): fast-moving tools (
bat,yq,neovim,helix,starship,eza,zoxide,git-delta,lazygit,gh), the brew-only LSP tools (marksman,shellcheck), and thenode/uvtoolchains. Seeroles/homebrew/vars/main.yml. - Go (
go install): Go LSP/formatter/linter set —gopls,dlv,goimports,gofumpt,golangci-lint,staticcheck,yamlfmt,shfmt,efm-langserver,helm-ls, plusterraform-ls(built from source rather than HashiCorp's brew tap, to avoid that tap's trust gate). - Cargo (
cargo install):taplo-cli,harper-ls,dprint,markdown-oxide(git source). - npm (
npm i -g): language servers for Ansible, JSON/HTML/CSS, Dockerfile, Docker Compose, YAML, Bash, plusmarkdownlint-cliandprettier. - uv (
uv tool install):python-lsp-server,black.
The lang_tools lists live in roles/lang_tools/vars/main.yml. They mirror
what jsco2t/dotfiles's .config/helix/deps.sh installs, expressed as
idempotent Ansible.
curl -fsSL https://raw.githubusercontent.com/jsco2t/devbox-provision/main/bootstrap.sh | bashEnsures git, clones the repo to a location you choose (default
<cwd>/devbox-provision), then runs update-env.sh, which installs Ansible +
the community.general collection and converges the machine. Set
PROVISION_DEST=/path/to/dir to skip the interactive clone-location prompt.
After the first run there is a ~/update-env.sh wrapper (it cds into the
clone and re-runs its update-env.sh):
~/update-env.sh # fast converge: install only what's missing
~/update-env.sh --upgrade # bump every tool to its latest version
~/update-env.sh --check # dry run (--check --diff)Equivalently, from the clone itself: ./update-env.sh [--upgrade|--check].
Default vs. upgrade. A default run is fast and idempotent — it installs
missing tools and skips everything already present (go/cargo/npm/uv
install tasks are guarded on the resulting binary; Homebrew and apt/dnf use
state: present and skip brew update). --upgrade is the slow path: it runs
brew update, state: latest, re-fetches the language tools at @latest, and
runs rustup update.
~/update-env.sh --check
# or directly:
ansible-playbook -i 'localhost,' -c local local.yml --check --diffYour git identity is written to the dotfiles bare repo via config --local
(exactly as the original .dotsetup.sh did; it does not touch global git
config). Two ways to set it:
Environment variables (no repo edit needed):
export DOTFILES_USER_NAME="Your Name"
export DOTFILES_USER_EMAIL="you@example.com"
export DOTFILES_REPO_URL="https://github.com/you/dotfiles.git" # optionalThese are read at run time by group_vars/all.yml. If a variable is unset or
empty, the fallback baked into group_vars/all.yml is used.
Or edit the fallbacks in group_vars/all.yml if you own/fork this repo:
dotfiles_user_name: "{{ lookup('env', 'DOTFILES_USER_NAME') | default('Jason', true) }}"
dotfiles_user_email: "{{ lookup('env', 'DOTFILES_USER_EMAIL') | default('you@example.com', true) }}".github/workflows/ci.yml runs on every push to main (and on demand via
workflow_dispatch). It spins up a Debian container, creates an unprivileged
user, and runs update-env.sh three times:
- setup — a default converge installs everything that's missing,
--upgrade— exercises the bump-to-latest path,- default again — asserts the steady-state converge reports
changed=0(the idempotency guarantee).
- The dotfiles role does not run the original destructive
rm -frpreamble. It skips the bare clone if~/.dotfilesalready exists and usesreset --hard origin/mainto apply tracked files, leaving untracked files in$HOMEalone. - On Linux, ensure your dotfiles put the toolchains on
PATH:eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)", plus~/.cargo/bin,~/go/bin, and~/.local/bin(cargo / go / uv install targets).