|
| 1 | +# Lessons |
| 2 | + |
| 3 | +## Bash: no apostrophes / single quotes inside `${var:-default}` defaults |
| 4 | + |
| 5 | +**Symptom:** `bash -n install-caddy.sh` reported `syntax error near unexpected |
| 6 | +token '('` on a line whose `(` was harmlessly inside a double-quoted string — and |
| 7 | +the *real* offending code was ~14 lines earlier. |
| 8 | + |
| 9 | +**Cause:** a default value contained an apostrophe: |
| 10 | +`note="... (-> ${pub:-this host's inbound public IP}) ..."`. Even inside double |
| 11 | +quotes and inside `${...:-...}`, bash treats that `'` as a quote delimiter and pairs |
| 12 | +it with the *next* single quote in the file (here, the opening `'` of a later |
| 13 | +`printf '{...}'` format string). Everything between is mis-quoted, desyncing all the |
| 14 | +double-quote accounting, so the parser blows up on a much later line. |
| 15 | + |
| 16 | +**Rule:** never put `'` (including apostrophes in prose) inside a `${var:-word}` / |
| 17 | +`:+` / `:=` default. Rephrase to avoid the apostrophe. The codebase already follows |
| 18 | +this — e.g. `${pub:-this servers public IP}` (no apostrophe in "servers"). Match it. |
| 19 | + |
| 20 | +**Bonus:** when `bash -n` points at a line that looks obviously fine, suspect an |
| 21 | +unbalanced quote *earlier* in the file, not the reported line. |
| 22 | + |
| 23 | +## Bash: `local a; a="$(cmd)" b` runs `b` as a command and drops `a` |
| 24 | + |
| 25 | +**Symptom:** shellcheck SC2154 ("b is referenced but not assigned") on a resolver I |
| 26 | +wrote as `local etc; etc="$(_tn_etc)" var; ...; printf '%s' "$var"` — and at runtime |
| 27 | +`$var`/`$etc` would have been **empty**. `bash -n` passed (valid syntax) so it nearly |
| 28 | +slipped through; only shellcheck + a fixture unit test caught it. |
| 29 | + |
| 30 | +**Cause:** `etc="$(_tn_etc)" var` is parsed as *run the command `var` with the env var |
| 31 | +`etc` set for that one command*. Prefix assignments are temporary — `etc` is NOT |
| 32 | +retained in the shell afterward — and `var` (or `t`, `n`, …) is executed as a bogus |
| 33 | +command. Six resolver functions had this shape; every one was silently broken. |
| 34 | + |
| 35 | +**Rule:** declare all locals on the `local` line, then assign on their own lines: |
| 36 | +`local etc var; etc="$(_tn_etc)"; var="$(_tn_var)"`. Never trail a bare word after a |
| 37 | +`VAR="$(...)"` assignment expecting it to be another local. Always shellcheck + |
| 38 | +fixture-test library resolvers before building consumers on top of them. |
| 39 | + |
| 40 | +## Verifying bash scripts that gate on `check_root` / do real side effects |
| 41 | + |
| 42 | +To unit-test functions in a script that ends with `main "$@"` and whose interactive |
| 43 | +path calls `check_root` + real installers: strip the trailing `main "$@"` into a temp |
| 44 | +copy (`grep -v '^main "\$@"$'`), symlink `lib/` next to it so `source lib/common.sh` |
| 45 | +resolves, `source` it, then override the side-effecting/network functions with stubs |
| 46 | +and call the target function directly. Drive `read` prompts via piped stdin. Note: |
| 47 | +`read -p` shows its prompt **only when stdin is a TTY**, so a piped harness won't see |
| 48 | +prompt text — assert on resulting behavior/state instead. For network-dependent |
| 49 | +helpers (curl/dig/hostname), prefer PATH shims that echo env-var-controlled values so |
| 50 | +every branch is deterministic. |
0 commit comments