Bootstrap a dev machine in one command
The onboarding wiki everyone follows differently. The setup script that only works on one OS. Here's how to turn 'install these 15 tools' into a single idempotent OrchStep command that runs the same on macOS and Linux.
blog/machine-bootstrapYou know the page. It's titled "Setting up your dev environment" and it lives in the wiki. Step 1: install Homebrew. Step 2: brew install git. Step 3 through 15: a wall of install commands someone wrote in 2022, half of which are now out of date.
Everyone follows it differently. One person skips ripgrep because they "don't use it." Another installs jq from a tarball. A third is on Linux, and the whole page is brew-only, so they improvise. Three months later nobody can reproduce a teammate's bug because nobody has the same tools.
The usual fix is a bootstrap.sh. It works — on the machine of the person who wrote it. It hardcodes brew, so it breaks on the Linux box. It re-installs everything on every run because it never checks first. And the list of tools is buried in the middle of a for loop nobody wants to edit.
This post replaces that wiki page and that script with one OrchStep workflow: a single package list, one command, idempotent, and OS-aware. Same brew and apt-get underneath — just with a shape you can read.
The whole thing
Here's the runnable project. It's deliberately small: a packages list, an OS check, and a loop that installs each tool.
name: machine-bootstrap
# Take a fresh laptop to "ready to code" in one command.
# Idempotent: every install checks first, so re-running is a no-op.
# The same package list runs on every machine, mac or linux.
defaults:
# A real YAML list. `loop` references it as a template string below
# ({{ vars.packages }}) — never as an inline JSON array.
packages: ["git", "jq", "ripgrep", "fzf"]
tasks:
# `orchstep run setup`
setup:
steps:
# 1. Detect the OS so later steps can branch mac vs linux.
- name: detect_os
func: shell
do: uname -s
outputs:
kernel: "{{ result.output }}"
# 2. Pick the package manager for this OS (mac -> brew, linux -> apt-get).
- name: pick_manager
if: '{{ eq steps.detect_os.kernel "Darwin" }}'
then:
- name: mac_manager
func: shell
do: echo "brew"
outputs:
manager: "{{ result.output }}"
else:
- name: linux_manager
func: shell
do: echo "apt-get"
outputs:
manager: "{{ result.output }}"
# 3. Refuse to continue if no package manager is present.
- name: assert_manager
func: assert
args:
condition: '{{ ne steps.detect_os.kernel "" }}'
message: "could not detect a package manager (need brew on macOS or apt-get on Linux)"
# 4. Install each package, idempotently. The echo stands in for the
# real install — the check-then-install pattern is shown in the do block.
- name: install_packages
loop:
items: '{{ vars.packages }}'
func: shell
do: 'echo "ensure {{ loop.item }}: command -v {{ loop.item }} || install {{ loop.item }}"'
# 5. Confirm we are done.
- name: done
func: shell
do: echo "machine bootstrapped with git jq ripgrep fzf"Run it
orchstep run setupThat detects the OS, asserts a package manager exists, and walks the list installing each tool. Add a new tool by editing one line — the packages list — not a for loop. The list is the contract: everyone who runs this gets the same tools.
The list is a list (and the loop reads it as a string)
The single most common mistake here is worth calling out, because it bites everyone once:
defaults:
packages: ["git", "jq", "ripgrep", "fzf"] # a real YAML list - name: install_packages
loop:
items: '{{ vars.packages }}' # referenced as a template STRINGpackages is a genuine YAML list. But loop.items does not take an inline list — it takes a template string that resolves to a list. So you point it at the variable (the template-string form shown above), not an inline ["git", "jq", ...]. Define the list once in defaults, point the loop at it, and you can also override it per machine with --var packages='["git","go","docker"]'.
The echo is standing in for a real install
To keep the demo safe to run anywhere, every step only echos. In real life you swap the loop body for your package manager. The OS check already told you which one:
- name: install_packages
loop:
items: '{{ vars.packages }}'
func: shell
do: 'command -v {{ loop.item }} >/dev/null || brew install {{ loop.item }}'The command -v ... || guard is what makes this idempotent: each tool is only installed if it isn't already there. Run it on a fresh laptop and it installs everything; run it again tomorrow and it does nothing. No "did I already run this?" — re-running is always safe.
And because detect_os writes the kernel into an output, you don't have to maintain two scripts. One workflow branches on that detected kernel and picks brew install or sudo apt-get install -y for you.
See it before it touches the machine
Installing software is exactly the kind of thing you want to preview first. A dry-run resolves the variables, expands the loop, and prints the plan — without running a single install:
orchstep run setup --dry-runYou'll see all four packages enumerated, the OS branch marked as decided at runtime, and the assert that gates the whole thing — before anything executes. That's the difference between "I think this is safe to run on my laptop" and seeing the exact plan.
What you gained
| Concern | Wiki page + bootstrap.sh | OrchStep |
|---|---|---|
| The tool list | buried in a for loop / prose | one packages list at the top |
| Re-running | re-installs everything | command -v || install — no-op if present |
| macOS vs Linux | two scripts, or one that breaks | one workflow, branches on uname |
| "What will this do?" | run it and find out | --dry-run prints the plan |
| Same tools for everyone | hope people read carefully | the list is the contract |
| Add a tool | edit the loop, test on two OSes | add one line |
None of this hides your package manager. brew and apt-get still do the work — OrchStep just gives "install these 15 tools" a shape that's the same on every machine and safe to run twice.
Where to go next
- Quick Start — your first workflow in two minutes
- Loops — iterate a list with
loop.itemsandloop.item - Shell function —
do:, outputs, andresult.output - Assertions — gate a workflow on a precondition
- Branching —
if/then/elseon step outputs
Got a wiki page titled "Dev environment setup"? Turn its list into a packages: block and you'll never argue about who installed what again.
curl -fsSL https://orchstep.dev/install.sh | sh