Every dojo laptop begins as a bare Debian install. One script turns it into a working machine: window manager, editor, test runner, pairing tools, the lot. The fleet is two models of donated hardware, an HP EliteBook and a MacBook Pro from 2012, and the script detects which one it is standing on and adapts. It has grown to nine numbered sections and nearly twelve hundred lines, and this week it told us, twice, that it had outgrown a single file.
The first signal was small and embarrassing. Adding a tenth step meant editing seventeen echo lines, because every section banner hardcodes its position in the count, [3/9], [4/9], and so on. The second signal was structural. We wanted to offer tools that some machines should have and most should not, and a monolith has no natural place for optional.
What the monolith already knows
Before cutting anything, look at what the sections quietly share. Near the top, the script works out what it is running on:
VENDOR="$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo unknown)"
APPLE=0
case "$VENDOR" in
Apple*) APPLE=1 ;;
esac
That APPLE flag is consumed hundreds of lines later, where the package section picks a wifi driver:
if [ "$APPLE" -eq 1 ]; then
sudo apt install -y linux-headers-amd64 broadcom-sta-dkms
else
sudo apt install -y firmware-iwlwifi
fi
A second flag, HIDPI, is set by reading the panel width and consumed at the very end, where Retina machines get their font scaling. Add one log redirect that captures the whole run and a single set -euo pipefail guarding everything, and you have the real problem. The sections are not independent. They share a small amount of state and one error policy.
So splitting the script is not one decision, it is two. The first is where to cut, and the section banners already answer that. The second is how the pieces run, and shell gives you exactly two answers. The orchestrator can execute each module as its own process, or source each module into its own shell. The difference looks cosmetic. It is structural.
Option one: execute
The orchestrator stays tiny and runs each module as a child process:
export APPLE HIDPI NODE_VERSION
for module in "$SCRIPT_DIR"/setup.d/*.sh; do
echo "── $(basename "$module") ──"
bash "$module"
done
Because each module is a separate process, it is a complete program. It carries its own shebang and its own safety line, and it must collect its inputs from the environment:
#!/usr/bin/env bash
set -euo pipefail
APPLE="${APPLE:-0}"
if [ "$APPLE" -eq 1 ]; then
sudo apt install -y linux-headers-amd64 broadcom-sta-dkms
else
sudo apt install -y firmware-iwlwifi
fi
The strengths are real. Every module runs alone, so you can rerun the i3 step after a config tweak without sitting through the full provision. Every module lints alone under shellcheck. A module cannot trample another module's variables, because there is no shared shell to trample.
The cost is the contract. State now travels by environment variable, and nothing enforces the agreement. The orchestrator must remember to export, the module must remember to default, and when a new module needs a new fact about the machine, both ends change. With ten modules the drift is not hypothetical. It is a matter of time.
Option two: source
The orchestrator reads each module into its own shell instead:
set -euo pipefail
exec > >(tee -a "$LOG_FILE") 2>&1
for module in "$SCRIPT_DIR"/setup.d/*.sh; do
source "$module"
done
Now a module is a chapter, not a program. It needs no shebang, no safety line, no environment plumbing. The wifi choice reads exactly as it does in the monolith today, because it still runs in the shell where detection happened:
if [ "$APPLE" -eq 1 ]; then
sudo apt install -y linux-headers-amd64 broadcom-sta-dkms
else
sudo apt install -y firmware-iwlwifi
fi
Everything the sections share arrives free. One detection pass, one log capture, one error policy over the entire run. The orchestrator sets the rules once and every chapter inherits them.
The cost is the mirror of option one. These modules cannot run alone, and under set -u an attempt fails immediately on the first missing variable. Worse, the coupling is invisible. Any module may read a variable another module happened to leave behind, and nothing in the file tells you it does. The namespace is one big room.
The conclusion, from engineering and from resources
The engineering argument alone does not settle it. Execute gives you isolation and pays in contract maintenance. Source gives you shared state and pays in hidden coupling. Both are honest trades. What settled it for us was looking at who runs these scripts, and when.
A provision run is one transaction. Nobody wants half a dojo machine, and the run happens once, on a bench, over dojo wifi, taking a quarter of an hour. The base install is therefore one product, and its modules have no independent life. Sourcing fits that shape. Detection stays in one place, the log stays in one piece, and when a volunteer reports a failed setup we read a single file to see the whole story.
Optional tools are the opposite shape. They run weeks after provisioning, on a machine that is already alive, started by a person who should not need to know the orchestrator exists. The first candidate was a coding agent for the operations laptops, a tool the training floor deliberately does without, and that boundary should be visible in the repository layout, not buried in a flag. Standalone executable scripts fit that shape. Each one checks what it needs by itself, because it cannot assume anyone ran detection first.
So the layout became a hybrid, each mechanism used where its trade is the right one:
scripts/
ekohacks-dojo-setup.sh orchestrator, sources the base modules in order
setup.d/
00-preflight.sh root check, network check, hardware detection
01-packages.sh
...
09-shell.sh
optional/
install-claude-code.sh standalone, run by hand after provisioning
The resource argument matters as much as the engineering one. Two people maintain this fleet in spare hours, so the base install optimises for one readable log over per module purity, and the optional directory optimises for a script a colleague can run without reading anything else first. And the small embarrassment that started all this is gone quietly. The orchestrator derives the step count from the number of files it finds, so the eleventh module will not ask us to edit seventeen lines of banners.
The split did not make the setup smaller. It made each decision live where someone will actually look for it, and that is the property worth designing for.

