Shell Scripting
A shell script is a program — the same grammar from chapter 9, saved and executable. This chapter turns the grammar into software: the shebang contract, arguments, control flow, functions, the safety flags, and the idioms real scripts are built from — all in portable POSIX sh, with bash's extensions marked.
The skeleton — every script's preamble
#!/bin/sh
# what this script does — one honest comment line
set -eu # -e die on error · -u die on unset vars
IFS='
' # optional: strict word-splitting hygiene
#!/bin/sh — the shebang — makes it a program; the kernel reads #! and hands the file to the named interpreter. chmod +x and it's runnable. The set flags are the professional preamble: -e stops at the first failure (no zombie scripts continuing after a cd failed), -u catches typo'd variables, -o pipefail (bash) makes pipelines propagate inner failures. #!/usr/bin/env bash for bash scripts specifically.
Arguments — the script's input
$0 # the script's own name
$1 $2 ... # positional args
$# # count
$@ # all args as separate words ("$@" — always quoted)
$* # all args as one word
shift # drop $1, everything moves down
if [ "$#" -lt 1 ]; then
echo "usage: $0 <file>" >&2
exit 1
fi
for f in "$@"; do ...; done # "$@" iterates args safely
Variables — scope and habits
var=value # NO SPACES around =
echo "$var" # always quoted
readonly VERSION=1.0 # constants
local x # function-local — bash; POSIX uses distinct names
export PATH="$PATH:/x" # into the environment
cmd=$(command) # capture output — the universal idiom
Control flow — the complete set
if [ -f "$file" ]; then
echo exists
elif [ -d "$file" ]; then
echo dir
else
echo missing
fi
case "$1" in
start) daemon_start ;;
stop) daemon_stop ;;
-h|--help) usage ;;
*) echo "unknown: $1"; exit 1 ;;
esac
for f in *.txt; do # glob-driven
process "$f"
done
for i in 1 2 3; do echo "$i"; done
for i in $(seq 1 10); do ...; done # numeric via seq
while IFS= read -r line; do # the file-reading idiom — THE way to read lines
printf '%s\n' "$line"
done < input.txt
until [ -f ready ]; do sleep 1; done # until-loop
The while IFS= read -r line loop is the canonical file iterator — read splits on IFS, -r keeps backslashes, the IFS= prefix preserves whitespace. Never for line in $(cat file) — it word-splits.
Functions
die() { echo "error: $*" >&2; exit 1; }
usage() { cat <<EOF
usage: $0 <args>
EOF
}
backup() {
local src="$1" dst="$2" # bash 'local'
cp -a "$src" "$dst" || die "backup failed: $src"
}
backup a/ b/ || exit 1 # functions return exit codes like commands
The safety machinery
set -eu # the preamble
set -x # trace — the debugging mode
trap 'cleanup' EXIT # guaranteed cleanup — THE pattern
trap 'echo "line $LINENO failed"' ERR # bash ERR trap
trap '' INT # ignore signals
tmp=$(mktemp -d) # safe tempdirs — always mktemp, never /tmp/foo.$$
trap 'rm -rf "$tmp"' EXIT
trap ... EXIT is the idiom that separates careful scripts from sloppy ones — temp files, lock files, mounted things — cleaned even on early failure.
printf over echo
printf '%s\n' "$var" # portable, predictable
printf '%-10s %5d\n' name 42 # formatted columns
echo "$var" # fine for simple; printf for format control
echo has portability landmines (-e, -n, backslashes); printf has none — use it for anything beyond a plain string.
The idioms that earn their keep
# arg-count check
[ "$#" -ge 1 ] || die "need a file"
# default value
name=${1:-default}
# required command
command -v gcc >/dev/null || die "gcc missing"
# quiet a command
cmd >/dev/null 2>&1
# the lock — prevent double-run
lock=/tmp/myapp.lock
exec 200>"$lock"; flock -n 200 || exit 0
# config override
: "${CONF:=/etc/myapp.conf}" # bash/POSIX default-assign
# dry-run flag
[ "$DRY" = 1 ] && echo "would rm $f" || rm "$f"
# the pipe-safe read
cat file | while read -r l; do x="$l"; done # subshell — x dies outside!
while read -r l; do x="$l"; done < file # same shell — x survives
getopts — parsing flags
usage() { echo "usage: $0 [-v] [-o out] files..."; }
verbose=0; out=
while getopts "vo:h" opt; do
case "$opt" in
v) verbose=1 ;;
o) out="$OPTARG" ;;
h) usage; exit 0 ;;
*) usage; exit 1 ;;
esac
done
shift $((OPTIND - 1)) # remaining args in $@
getopts is POSIX — the -v, -o arg grammar handled properly. bash's getopt (no s) and manual while/case on $1 handle --long-flags where getopts doesn't.
The safe-script template — the professional skeleton
#!/bin/sh
# what this does — one honest line
set -eu # die on error, unset vars
umask 022
tmp=$(mktemp -d) # a private tempdir
trap 'rm -rf "$tmp"' EXIT # guaranteed cleanup
trap 'echo "fail at line $LINENO" >&2' ERR # bash only
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
require() { command -v "$1" >/dev/null 2>&1 || die "$1 required"; }
require tar
require awk
main() {
[ "$#" -ge 1 ] || die "need an argument"
for f in "$@"; do
[ -f "$f" ] || die "not a file: $f"
process "$f"
done
}
main "$@"
The pieces: set -eu, mktemp -d + trap, die(), require() for tool checks, main "$@" as the entry — the shape every serious script takes.
The signal traps — the shutdown machinery
trap 'cleanup' EXIT # runs on any exit — even errors — THE idiom
trap 'kill $pid' TERM # graceful shutdown
trap '' INT # ignore Ctrl-C — the don't-interrupt-us zone
trap 'echo got sigusr1' USR1
trap - INT # reset to default
The debugging toolkit
sh -x script.sh # trace — THE debugger
sh -n script.sh # syntax-only check
bash -v script.sh # print lines as read
set -x ... set +x # trace a region
PS4='+ $LINENO: ' sh -x script.sh # line-numbered trace
shellcheck script.sh # the linter — the real debugging tool
shellcheck catches the quoting bugs, the for x in $(ls), the unguarded rm — run it on every script.
The idioms library
# default value
: "${VAR:=default}"
# required var
: "${VAR:?must set VAR}"
# command exists
command -v cmd >/dev/null || die "missing cmd"
# retry n times
for i in 1 2 3; do cmd && break || sleep 1; done
# run-as-user
sudo -u svcacct script.sh
# the lock — only one instance
exec 9>/tmp/myapp.lock; flock -n 9 || exit 0
# atomic write
tmp=$(mktemp) && write > "$tmp" && mv "$tmp" target
# the here-doc to file
cat > file <<'EOF'
content — quoted EOF = literal, unquoted = expands
EOF
# parallel jobs with wait
for x in a b c; do work "$x" & done; wait
# read config key=val pairs
while IFS='=' read -r k v; do case "$k" in name) NAME="$v";; esac; done < cfg
POSIX vs bash — what you gain and lose
# bash powers worth the shebang
arr=(a b c); echo "${arr[1]}" # arrays
[[ "$s" =~ ^[0-9]+$ ]] # regex test
${var/pat/rep} # substitution
(( i++ )) # arithmetic syntax
select opt in a b; do ...; done # menus
declare -A map; map[k]=v # associative arrays
Write POSIX unless you need the extension — then say #!/bin/bash plainly and use it.
Debugging — the honest workflow
sh -x script.sh # trace every step — the debugger
bash -n script.sh # syntax-check without running
set -x; ...; set +x # trace a region
echo "DEBUG: var=$var" >&2 # printf-style probes to stderr
set -x plus trap plus >&2 diagnostics covers most script debugging without a real debugger.
Where scripts go wrong
| Symptom | Cause |
|---|---|
var=x: command not found | Spaces around = — var = x isn't assignment |
| Loop var empty outside the loop | | while = subshell — < file instead |
[ $x = 1 ] errors on empty x | Unquoted — [ "$x" = 1 ] |
| Script says "file not found" | CRLF line endings — the shebang line has \r |
| Works in bash, dies in sh | #!/bin/sh script with bashisms |
| Temp file collision | Used a fixed name — mktemp |
| Script keeps going after failure | Missing set -e |
read ate the pipe | for+$(cat) instead of while-read |
Exercises
- Write
backup.sh— arg check,cp -a,dieon failure, usage text. - The while-read vs for-in-cat difference — prove the subshell loss.
trapa temp dir's cleanup — exit early mid-script, verify it still cleaned.sh -xa script you wrote — read the trace.- Port a bash script (
[[, arrays) to strict POSIX — what's actually needed?