| name | setup-module-registry |
| description | Every cmd_init step except system-deps lives in scripts/setup/NN-<name>.sh as a self-registering "module" with a uniform read/prompt/write/apply contract. Use this when adding a new setup-time decision, refactoring a `read -rp` that re-asks on re-runs, or wiring a setting into `just doctor`. |
Setup module registry
setup::init is idempotent and re-runnable — after a partial failure, to pick up a newly-selected feature, or via setup::reconfigure. Every decision it prompts for has to persist, or that re-run re-asks it. A bare read -rp that writes only a local variable is exactly that leak; this convention closes it by giving every decision a .env-backed home that the engine reads before deciding whether to prompt.
The contract
Every setup decision lives in its own file under scripts/setup/NN-<name>.sh:
#!/usr/bin/env bash
prompt_<name>() {
}
apply_<name>() {
}
register_module <name> <KEY> prompt_<name> apply_<name> [<when>] [<features>]
<when> (config default | deferred) and <features> (a comma-list
feature gate; empty = always relevant) are optional — see the two sections
below.
scripts/setup/_lib.sh is the engine. It exposes:
register_module — validates that prompt_fn / apply_fn are real defined functions at source time (catches typos before any user sees them) and appends to SETUP_MODULES.
read_env_key <KEY> / write_env_key <KEY> <VAL> — single source of truth for .env I/O. Don't hand-roll grep/sed for .env; use these. They live in scripts/common.sh, not _lib.sh: they're generic persistence helpers (CLAUDE.md: ".env is the persistence layer"), not registry internals. common.sh is sourced before _lib.sh everywhere, so the engine still resolves them.
module_relevant <name> — true if the module's <features> gate is empty or overlaps the live BAR_FEATURES selection (via feature_selected, also in common.sh). cmd_init wraps each gated module's ensure_module_by_name in it; summarize_modules / apply_deferred_modules skip the modules it rejects.
ensure_module <entry> — drives one module: read existing value; if empty (or BAR_RESET_CONFIG=1), call prompt_<name>; then call apply_<name>.
ensure_module_by_name <name> — same, looked up by registered name. Used by just <module>::setup recipes so they share lifecycle with cmd_init.
ensure_all_modules — iterate every module in registration order with auto-numbered step headers.
doctor_modules — read-only iteration; prints the registry as a table for just doctor. Deliberately not gated by module_relevant — an <unset> row for a gated-out module is a valid diagnostic.
summarize_modules — read-only iteration for confirm_setup_plan's pre-flight rollup; prints a module's optional summary_<name> if defined, else the raw KEY = value. Skips modules module_relevant rejects.
cmd_init's configuration phase becomes a flat sequence of ensure_module_by_name <name> calls. There is no read -rp outside a module file — that's what caused the re-ask leaks before this convention.
Selection vs action modules
Modules fall into two shapes:
- Action modules materialize at config time.
apply_<name> does the work immediately after the prompt:
chobby_channel: writes <data-dir>/chobby_config.json.
ssh: runs scripts/ssh/setup-<choice>-ssh.sh.
- Selection modules record a value that downstream steps in
cmd_init consume:
features: persists BAR_FEATURES; clone/build/link steps read it via read_env_key.
link_on_build: persists BAR_LINK_ON_BUILD; the symlinks step at the end of cmd_init reads it.
For selection modules, apply_<name>() { :; } (true no-op). The downstream code in cmd_init uses read_env_key BAR_<KEY> to drive its decision. Do not duplicate the apply logic between the module and cmd_init — pick one home; selection modules put the home in cmd_init's ordered phases, action modules put the home in apply_<name>.
Deferred apply: register_module ... deferred
Some modules' apply touches state that cmd_init only creates later (clones, builds, the bar-dev distrobox). Running their apply during the front-loaded config phase explodes because that state isn't there yet.
The fifth argument to register_module is when, defaulting to config. Pass deferred for modules that need to wait:
register_module editor BAR_EDITOR_SETUP prompt_editor apply_editor deferred bar,recoil
ensure_module for a deferred module runs only the prompt; apply_deferred_modules (called near the end of cmd_init, after distrobox/clones/builds) iterates and runs the deferred applies. The user still sees one prompt batch at the top — the apply just shifts in time.
This is bash's stand-in for the depends-on graph a real config-management tool would express. Keep deferred to the genuinely-needs-later-state cases; don't tag everything deferred to "be safe".
Feature-gating: the 6th register_module arg
A module relevant only to some features takes a comma-list <features> gate
as the sixth argument. module_relevant is true when the gate is empty or
overlaps the live BAR_FEATURES; cmd_init wraps the module's
ensure_module_by_name in if module_relevant <name>; then ...; fi, and
summarize_modules / apply_deferred_modules skip what it rejects. So a
teiserver-only contributor is never asked about the Chobby channel,
springsettings, the editor toolchain, or game-dir symlinks.
The sixth arg sits after <when>, so a gated module must state <when>
explicitly even when it's the config default:
register_module springsettings ALLOW_SPRINGSETTINGS_MOD \
prompt_springsettings apply_springsettings config bar,recoil
features and ssh are ungated (every selection clones repos and may need
SSH). The gate lives at the cmd_init call site only — just <module>::setup
still re-prompts any module directly, gate or no gate. Keep gates honest:
list the features a module's decision actually affects, nothing more.
Fail loudly inside apply_
set -e propagates non-zero through bare commands but not through pipes (without pipefail), through 2>/dev/null swallowed errors, or through unchecked loop iterations. Several silent-failure shapes used to hide here:
- Loops over commands without per-iteration check. The original
for bin in ...; do distrobox-export "$bin" ...; done swallowed each export's exit code. Fix: capture exit, accumulate failures, err+return 1 at the end of the loop. Whoever runs setup::init finds out which binary failed.
cmd | tail -3 style pipes. tail always succeeds; cmd's exit goes nowhere. Fix: set -eo pipefail inside the bash -c, or ${PIPESTATUS[0]} checks.
bash -c '<multi-line>' without set -e inside. The inner script keeps going past failures by default. Fix: first line of the inner script is set -e.
- Bare
echo "$cmd" 2>/dev/null that masks all errors. Only acceptable for genuinely-expected failures (probe-and-fall-back); not for installs/exports/configures.
Pattern: every apply_ should either succeed cleanly OR err+return 1. The user-visible message tells them what failed and how to recover (Re-run 'just setup::editor', Run 'just setup::distrobox' first).
cmd_init keeps its ensure_module_by_name <name> || true wrappers so a single module's apply failure doesn't abort the whole flow — but the module's loud err is what tells the user which module failed and what to do.
File layout and order
scripts/setup/
├── _lib.sh # engine. Skipped by the loader (underscored).
├── 20-features.sh
├── 25-link-on-build.sh
├── 30-chobby-channel.sh
├── 40-ssh.sh
├── 50-editor.sh
└── 60-springsettings.sh
The loader globs [0-9]*.sh in alphanumeric order. Use the numeric prefix as a topological hint: a module that needs another module's value already in .env should come after it. (e.g. chobby_channel needs BAR_DATA_DIR from earlier; the prefix 30 puts it after the data-dir resolution.) Don't do a runtime topological sort — the prefix IS the convention.
_load_setup_modules is called from the bottom of setup.sh, after every helper the modules call (checkbox_list, info/warn, repo helpers, read_env_key) is already defined. Modules can't use forward references.
Why bash, what intellisense looks like
Setup runs before python3, pipx, and distrobox are guaranteed to exist. A Python rewrite makes orchestration cleaner but introduces a bootstrap problem worse than the orchestration itself. Stay in bash; mitigate the stringly-typed function-pointer cost with discipline:
register_module runs declare -F "$prompt_fn" and declare -F "$apply_fn" — typos surface at source-load, not at user-prompt time.
- Strict naming:
prompt_<name>, apply_<name>, read_<name> / summary_<name> (both optional). Grep is the index.
# shellcheck source=scripts/setup/_lib.sh directive at the top of each module file keeps shellcheck cross-file checks working.
- The engine itself (
ensure_module / _load_module_entry) is ~30 lines. Trace it once; the indirection cost is bounded.
Adding a module
- Pick a number prefix that places it after any module whose
.env value yours depends on, before any module that depends on yours.
- Drop a file at
scripts/setup/NN-<name>.sh with prompt_<name> + apply_<name> + register_module.
- Wire it into
cmd_init with ensure_module_by_name <name> || true (the || true lets a module decline gracefully — e.g., prompt_features returning 1 if the user picked nothing). If the decision is feature-specific, give register_module a 6th <features> arg and wrap the call in if module_relevant <name>; then ...; fi.
- (Action module only) — make sure
apply_<name> is idempotent: re-running on a 2nd setup::init invocation must not do destructive work. The pattern is: read current state, compare to desired, no-op if equal.
- (Selection module only) — wire the downstream consumer to read
BAR_<KEY> via read_env_key, not from a local-variable holdover.
- Add a recipe
just <module>::setup whose body is ensure_module_by_name <name> if standalone re-prompting is useful (bar::dev-mode is the precedent: it calls apply_chobby_channel directly because the recipe's whole job is "force the value" without re-prompting).
What this convention disallows
read -rp anywhere outside prompt_<name>. If you're reaching for it, write a module instead.
- Hand-rolled
.env reads/writes. Use read_env_key / write_env_key.
- "Skip if .env has the key" guards inside
prompt_<name>. ensure_module owns that gate. A second in-prompt guard silently ignores BAR_RESET_CONFIG — exactly the bug that wedged setup::reconfigure until the legacy prompt_ssh_setup_choice / prompt_editor_setup_choice / prompt_springsettings_opt_in guards were removed. A prompt may still short-circuit on a probe (e.g. ssh's _github_ssh_works autodetect) — but gate that on BAR_RESET_CONFIG so a reconfigure still asks.
- Cross-module reaches in
apply_<name>. If you need BAR_DATA_DIR from inside apply_chobby_channel, call read_env_key BAR_DATA_DIR. Don't depend on call ordering or shared globals.
What's special about cmd_doctor
check_doctor_modules calls doctor_modules, which iterates SETUP_MODULES and prints <name> <KEY> <value>. Adding a module gets you a doctor row for free — no separate doctor-side hardcoded list to update. This was the load-bearing reason for picking the registry over the simpler _module_lifecycle helper: doctor stays in sync without anyone remembering to update it.
The escape hatch: BAR_RESET_CONFIG=1
ensure_module checks ${BAR_RESET_CONFIG:-} before consulting .env. Setting it to anything non-empty forces every module to re-prompt regardless of persisted state. One knob, applied uniformly because every module reads it the same way through the engine. Don't add per-module reset flags.