BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

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.

Jun 26, 2026 OrchStep Team 6 minROLE: AnySCALE: Any
RUNNABLE DEMO
Full source for this post: blog/machine-bootstrap
VIEW SOURCE

You 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.

orchstep.yml
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 setup

That 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 STRING

packages 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-run

You'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

ConcernWiki page + bootstrap.shOrchStep
The tool listburied in a for loop / proseone packages list at the top
Re-runningre-installs everythingcommand -v || install — no-op if present
macOS vs Linuxtwo scripts, or one that breaksone workflow, branches on uname
"What will this do?"run it and find out--dry-run prints the plan
Same tools for everyonehope people read carefullythe list is the contract
Add a tooledit the loop, test on two OSesadd 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.items and loop.item
  • Shell functiondo:, outputs, and result.output
  • Assertions — gate a workflow on a precondition
  • Branchingif / then / else on 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.

#SETUP#MACOS#LINUX#ONBOARDING
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY