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
# shellcheck source=scripts/setup/_lib.sh
# Module: <name>.
prompt_<name>() {
# Interactive picker. On success, calls write_env_key <KEY> <value>.
# No materialization side effects -- writing to .env is the only
# state mutation here.
}
apply_<name>() {
# Read the persisted value via read_env_key <KEY> and do the real
# work. No prompts, no .env writes. Idempotent: must be safe to
# call repeatedly.
}
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 thatprompt_fn/apply_fnare real defined functions at source time (catches typos before any user sees them) and appends toSETUP_MODULES.read_env_key <KEY>/write_env_key <KEY> <VAL>— single source of truth for.envI/O. Don't hand-roll grep/sed for.env; use these. They live inscripts/common.sh, not_lib.sh: they're generic persistence helpers (CLAUDE.md: ".env is the persistence layer"), not registry internals.common.shis sourced before_lib.sheverywhere, so the engine still resolves them.module_relevant <name>— true if the module's<features>gate is empty or overlaps the liveBAR_FEATURESselection (viafeature_selected, also incommon.sh).cmd_initwraps each gated module'sensure_module_by_namein it;summarize_modules/apply_deferred_modulesskip the modules it rejects.ensure_module <entry>— drives one module: read existing value; if empty (orBAR_RESET_CONFIG=1), callprompt_<name>; then callapply_<name>.ensure_module_by_name <name>— same, looked up by registered name. Used byjust <module>::setuprecipes so they share lifecycle withcmd_init.ensure_all_modules— iterate every module in registration order with auto-numberedstepheaders.doctor_modules— read-only iteration; prints the registry as a table forjust doctor. Deliberately not gated bymodule_relevant— an<unset>row for a gated-out module is a valid diagnostic.summarize_modules— read-only iteration forconfirm_setup_plan's pre-flight rollup; prints a module's optionalsummary_<name>if defined, else the rawKEY = value. Skips modulesmodule_relevantrejects.
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: runsscripts/ssh/setup-<choice>-ssh.sh.
- Selection modules record a value that downstream steps in
cmd_initconsume:features: persistsBAR_FEATURES; clone/build/link steps read it viaread_env_key.link_on_build: persistsBAR_LINK_ON_BUILD; the symlinks step at the end ofcmd_initreads 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:
# editor's apply runs distrobox-export, which needs the bar-dev container
# created at cmd_init step 2/N. Tagging deferred so prompt fires at config
# time (front-loaded) but apply waits until apply_deferred_modules is called
# at the end of cmd_init.
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" ...; doneswallowed each export's exit code. Fix: capture exit, accumulate failures,err+return 1at the end of the loop. Whoever runssetup::initfinds out which binary failed. cmd | tail -3style pipes.tailalways succeeds; cmd's exit goes nowhere. Fix:set -eo pipefailinside thebash -c, or${PIPESTATUS[0]}checks.bash -c '<multi-line>'withoutset -einside. The inner script keeps going past failures by default. Fix: first line of the inner script isset -e.- Bare
echo "$cmd" 2>/dev/nullthat 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_modulerunsdeclare -F "$prompt_fn"anddeclare -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.shdirective 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
.envvalue yours depends on, before any module that depends on yours. - Drop a file at
scripts/setup/NN-<name>.shwithprompt_<name>+apply_<name>+register_module. - Wire it into
cmd_initwithensure_module_by_name <name> || true(the|| truelets a module decline gracefully — e.g.,prompt_featuresreturning 1 if the user picked nothing). If the decision is feature-specific, giveregister_modulea 6th<features>arg and wrap the call inif module_relevant <name>; then ...; fi. - (Action module only) — make sure
apply_<name>is idempotent: re-running on a 2ndsetup::initinvocation 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>viaread_env_key, not from a local-variable holdover. - Add a recipe
just <module>::setupwhose body isensure_module_by_name <name>if standalone re-prompting is useful (bar::dev-modeis the precedent: it callsapply_chobby_channeldirectly because the recipe's whole job is "force the value" without re-prompting).
What this convention disallows
read -rpanywhere outsideprompt_<name>. If you're reaching for it, write a module instead.- Hand-rolled
.envreads/writes. Useread_env_key/write_env_key. - "Skip if .env has the key" guards inside
prompt_<name>.ensure_moduleowns that gate. A second in-prompt guard silently ignoresBAR_RESET_CONFIG— exactly the bug that wedgedsetup::reconfigureuntil the legacyprompt_ssh_setup_choice/prompt_editor_setup_choice/prompt_springsettings_opt_inguards were removed. A prompt may still short-circuit on a probe (e.g. ssh's_github_ssh_worksautodetect) — but gate that onBAR_RESET_CONFIGso a reconfigure still asks. - Cross-module reaches in
apply_<name>. If you needBAR_DATA_DIRfrom insideapply_chobby_channel, callread_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.