latency.lab
Module 00

Shell, Shebangs & the First Script

Level: beginnerTime: ~40 minPrereq: a terminal

By the end you can

Everyone "knows" bash. Almost nobody knows what it actually does between you pressing Enter and something happening. That gap is where every confusing shell bug lives, so we start by closing it — then we set up the two habits that will keep you out of trouble for the rest of the course.

What bash actually is

Bash is three things wearing one coat, and conflating them is the source of a lot of confusion:

  1. An interactive interface — the prompt, history, tab completion, job control.
  2. A command interpreter — it finds programs and runs them.
  3. A programming language — variables, functions, conditionals, loops.

The third one is the one people skip, and it's why so much shell code in the wild is written by superstition. This course treats bash as a language you learn properly, not a place you paste commands.

What happens when you press Enter

For every line you type, bash runs a fixed pipeline. Knowing the order explains almost every "why did it do that?" moment you will ever have:

  1. Split into words on whitespace.
  2. Expand — this is a whole sequence in itself: brace expansion, tilde, then parameter/variable expansion, command substitution and arithmetic, then word splitting, then filename globbing.
  3. Redirect — set up any >, <, or pipes.
  4. Locate the command — a function? a builtin? something on PATH?
  5. fork + exec — for an external program, bash clones itself and replaces the clone with the new program.
Remember this

Expansion happens before the command runs, and word splitting happens after your variable is substituted. That single ordering fact is why an unquoted variable holding my file.txt arrives at the command as two arguments, not one. Module 01 is entirely about this.

The shebang

The first line of a script tells the kernel which interpreter to run it with. It only works if it is genuinely the first two bytes of the file — no blank line, no comment, no byte-order mark above it.

shebang.sh
#!/usr/bin/env bash   # find bash on PATH — portable, preferred
#!/bin/bash           # a fixed path — fine on Linux, wrong on some BSDs
#!/bin/sh             # POSIX shell — NOT bash. No arrays, no [[ ]].

#!/usr/bin/env bash is the default you should reach for: it finds whichever bash is first on PATH, which on a Mac with Homebrew bash is the modern one rather than Apple's ancient 3.2. The tradeoff is that it obeys PATH, so in a security-sensitive setuid-adjacent context a fixed path is safer — we return to that in Module 17.

Writing #!/bin/sh at the top of a script full of bash features is the single most common portability bug in this whole subject. On Debian and Ubuntu /bin/sh is dash, which has no arrays and no [[ ]]. The script will work on your machine and fail on the target. Module 15 is dedicated to this.

Three ways to run a script — and why they differ

terminal
./script.sh      # needs +x and a shebang. Runs in a NEW process.
bash script.sh   # no +x needed, shebang ignored. Also a NEW process.
source script.sh # runs in YOUR CURRENT shell. No new process.
. script.sh      # identical to source — the POSIX spelling

The distinction is not cosmetic. A new process gets a copy of your environment, so anything it changes — variables, the working directory — vanishes when it exits. That is why a script that ends in cd /somewhere appears to "not work": it did work, in a process that no longer exists.

The habit to build

If a script's job is to change your current shell (set variables, activate an environment, change directory), it must be sourced, and you should say so in its header comment. Everything else should be executed. When you find yourself reaching for source to make a normal script's side effects stick, that's a design smell — have it print the values instead and let the caller decide.

How a command is actually found

When you type a name, bash resolves it in a strict order — and it is not "search PATH":

  1. Alias (interactive shells only)
  2. Function you have defined
  3. Builtin — cd, echo, test live inside bash itself
  4. PATH lookup — first match wins, left to right

Use type -a name to see every match in order — it is strictly more informative than which, which only knows about step 4 and is an external program on some systems. For scripts, use command -v name: it is a builtin, it is POSIX, and it is the correct way to ask "does this exist?".

terminal
$ type -a echo
echo is a shell builtin
echo is /bin/echo          # two different echoes, subtly different flags!

# The right existence check in a script:
if command -v jq >/dev/null 2>&1; then ...

The safety header

Start every script you write from here on with this. We will unpack each flag properly in Module 04 — including the important caveat that set -e is subtler than it looks — but adopt it now so the habit is automatic:

header.sh
#!/usr/bin/env bash
set -euo pipefail

# -e  exit on an unhandled command failure
# -u  treat an unset variable as an error (catches typos in variable names)
# -o pipefail  a pipeline fails if ANY stage fails, not just the last one

Without pipefail, grep pattern file | sort reports success even when grep found nothing, because sort succeeded. That default has silently broken a lot of production scripts.

Install shellcheck now, not later

shellcheck is a static analyser for shell. It catches the entire class of quoting bugs Module 01 is about, plus a few hundred others, and it explains each one with a wiki link. Treat a shellcheck warning like a compiler error.

terminal
brew install shellcheck      # macOS
sudo apt install shellcheck  # Debian/Ubuntu

shellcheck myscript.sh
macOS: check your bash version first

Apple still ships bash 3.2 from 2007, because bash 4 changed to GPLv3. Associative arrays (Module 07), mapfile, and &>> all need bash 4+. Run bash --version. If it says 3.2, run brew install bash — the course assumes 4.0 or newer, and several modules will simply fail on 3.2 in ways that look like your mistake rather than the shell's age.

Takeaways

  • Bash expands before it runs, and splits words after substituting variables — that ordering explains most surprising behaviour.
  • #!/usr/bin/env bash by default; #!/bin/sh means POSIX shell, not bash, and will break arrays and [[ ]].
  • Executing runs in a new process (changes are lost); sourcing runs in your current shell (changes persist).
  • Command lookup is alias → function → builtin → PATH. Use command -v in scripts, type -a when debugging.
  • Every script starts with set -euo pipefail, and every script gets run through shellcheck.

Try it: the two checks every real script needs

Almost every serious script begins by confirming its environment: is the interpreter new enough, and are the tools it depends on actually present? Implement both checks the correct way — no which, no parsing of version strings by hand.

Live

Loading starter code…

Checkpoint · Shell & Scripts

5 questions · pass at 70%

Q01
A script's last line is cd /var/log. You run it with ./go.sh, and afterwards your terminal is still in your home directory. Why?
WhyThis is the single most common 'my script doesn't work' report, and the script is usually working perfectly. ./go.sh forks a new process; that process changed its own working directory and then died. Nothing a child process does to its environment can propagate back to its parent. Sourcing (source go.sh) runs the commands in your current shell instead, which is why environment-activation scripts must always be sourced.
Q02
Why is command -v jq the right way to test whether a program exists in a script, rather than which jq?
Whywhich is an external binary whose behaviour and exit status genuinely differ across systems (the Debian version and the BSD version disagree), and on a minimal container it may not be installed at all — so your existence check itself fails to exist. command -v is built into the shell, standardised, and additionally reports functions and builtins, which matches how bash will actually resolve the name.
Q03
What specifically goes wrong when you put #!/bin/sh at the top of a script that uses arrays and [[ ]]?
WhyThe shebang is a promise about which language the file is written in. /bin/sh promises POSIX shell. Debian switched /bin/sh to dash in 2011 specifically because it starts faster, and dash implements POSIX and little else. The failure mode is nasty precisely because it is environment-dependent: identical code, works here, breaks there.
Q04
Why does set -o pipefail matter for a pipeline like grep ERROR app.log | sort?
WhyThis default has silently broken a great many production scripts. A pipeline reports only its final command's status, so any failure upstream is invisible — and since set -e keys off exit status, set -e alone will not save you. pipefail changes the pipeline's status to the rightmost non-zero one, making upstream failures visible to both you and set -e.
Q05
In what order does bash resolve a bare command name like echo?
WhyKnowing this order explains a whole category of confusion: why redefining a function named ls changes behaviour everywhere in your script, why “echo” has subtly different flag handling than /bin/echo, and — importantly for Module 17 — why an attacker who can prepend a directory to your PATH can substitute their own binary for one you meant to call.

Finished Module 00?

Passing the quiz marks it complete automatically. Or mark it yourself.