<!--date=20250206 -->


<h1>Shell Scripting</h1>
<p>Shell scripts are glue. They're not elegant programs — they're what you write at 11pm to automate the thing you just did by hand three times in a row. Once you get the basics solid, a lot of tedious sysadmin work just disappears. This covers the parts you actually use, with the gotchas that take a while to learn by burning yourself on them.</p>

<h2>The Shebang and Basic Structure</h2>
<pre>
#!/bin/bash
# Always start with a shebang — tells the system which interpreter to use.
# Without it, the script may run under /bin/sh, which has fewer features.

echo "Hello from a shell script"
</pre>

<pre>
# Make executable before running
$ chmod +x myscript.sh
$ ./myscript.sh

# Or run explicitly with bash (doesn't need +x):
$ bash myscript.sh
</pre>

<h2>Error Handling — Do This First</h2>
<p>By default, bash keeps going even when commands fail. This silently corrupts data and makes debugging miserable. Put these at the top of every serious script:</p>
<pre>
#!/bin/bash
set -e           # exit immediately on error
set -u           # treat unset variables as errors (catches typos)
set -o pipefail  # catch errors in pipes (not just the last command)

# Shorthand: set -euo pipefail
</pre>

<pre>
# Cleanup on exit with trap
TMPFILE=$(mktemp)

cleanup() {
    rm -f "$TMPFILE"
    echo "Cleaned up" >&2
}

trap cleanup EXIT   # runs on any exit, including errors and signals
trap cleanup SIGINT SIGTERM   # also on Ctrl+C and kill signals
</pre>

<h2>Variables</h2>
<pre>
NAME="jason"        # string
COUNT=42            # integer (all variables are strings internally)
EMPTY=""

# No spaces around the = (NAME = "jason" tries to run a command called NAME)
echo "Hello, $NAME"
echo "Count is ${COUNT}"      # braces: useful adjacent to other text
echo "Value: ${COUNT}px"      # without braces: ${COUNT}px vs $COUNTpx (broken)

# Default values
echo "${NAME:-unknown}"       # use "unknown" if NAME is unset or empty
echo "${NAME:=default}"       # assign default if unset
</pre>

<h2>Control Flow</h2>

<h3>If / Elif / Else</h3>
<pre>
if [ -f "/etc/hostname" ]; then
    echo "hostname file exists"
elif [ -d "/etc/hostname" ]; then
    echo "that's a directory"
else
    echo "not found"
fi
</pre>

<table>
  <thead><tr><th>Test</th><th>Meaning</th></tr></thead>
  <tbody>
    <tr><td><code>-f FILE</code></td><td>Regular file exists</td></tr>
    <tr><td><code>-d DIR</code></td><td>Directory exists</td></tr>
    <tr><td><code>-e PATH</code></td><td>Path exists (any type)</td></tr>
    <tr><td><code>-z STRING</code></td><td>String is empty</td></tr>
    <tr><td><code>-n STRING</code></td><td>String is not empty</td></tr>
    <tr><td><code>-r FILE</code></td><td>File is readable</td></tr>
    <tr><td><code>-w FILE</code></td><td>File is writable</td></tr>
    <tr><td><code>NUM1 -eq NUM2</code></td><td>Numbers are equal</td></tr>
    <tr><td><code>NUM1 -gt NUM2</code></td><td>Num1 greater than num2</td></tr>
    <tr><td><code>NUM1 -lt NUM2</code></td><td>Num1 less than num2</td></tr>
  </tbody>
</table>

<h3>Loops</h3>
<pre>
# Over a list
for COLOR in red green blue; do
    echo "Color: $COLOR"
done

# Over files (handles the case of no matches gracefully with nullglob)
shopt -s nullglob
for FILE in /var/log/*.log; do
    echo "Processing $FILE"
done

# C-style numeric loop
for ((i=0; i&lt;10; i++)); do
    echo "i = $i"
done

# While loop
COUNT=0
while [ $COUNT -lt 5 ]; do
    echo "Count: $COUNT"
    COUNT=$((COUNT + 1))
done

# Read lines from a file (safest method — handles spaces, special chars)
while IFS= read -r line; do
    echo "Line: $line"
done &lt; file.txt
</pre>

<h2>Functions</h2>
<pre>
log() {
    # $1 is the first argument, $2 is the second, etc.
    echo "$(date '+%Y-%m-%d %H:%M:%S') [$1] $2" >&2
}

backup() {
    local SOURCE="$1"    # local: scoped to function (no local = global, causes bugs)
    local DEST="$2"
    local TIMESTAMP
    TIMESTAMP=$(date +%Y%m%d_%H%M%S)

    log "INFO" "Backing up $SOURCE to $DEST"
    tar -czf "$DEST/backup_${TIMESTAMP}.tar.gz" "$SOURCE"
    log "INFO" "Done"
}

backup /home/jason /mnt/backups
</pre>

<h2>Working with Command Output</h2>
<pre>
# Capture output into a variable
HOSTNAME=$(hostname)
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}')

echo "Host: $HOSTNAME, Root disk: $DISK_USAGE"

# Check exit status (0 = success, non-zero = failure)
if grep -q "ERROR" /var/log/app.log; then
    echo "Errors found in app log" >&2
fi

# Combine: fail fast if the command fails
OUTPUT=$(some_command) || { echo "some_command failed" >&2; exit 1; }
</pre>

<h2>A Real Script: Log Rotation</h2>
<pre>
#!/bin/bash
set -euo pipefail

LOG_DIR="/var/log/myapp"
LOG_FILE="$LOG_DIR/app.log"
MAX_KEEP=7

if [ ! -f "$LOG_FILE" ]; then
    echo "No log file at $LOG_FILE" >&2
    exit 0
fi

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mv "$LOG_FILE" "$LOG_DIR/app_${TIMESTAMP}.log"
touch "$LOG_FILE"               # recreate the log file
chmod 644 "$LOG_FILE"           # restore permissions

# Keep only the MAX_KEEP newest rotated logs
ls -t "$LOG_DIR"/app_*.log 2>/dev/null \
    | tail -n +"$((MAX_KEEP + 1))" \
    | xargs rm -f

echo "Rotated log to app_${TIMESTAMP}.log. Kept last $MAX_KEEP archives."
</pre>

<h2>Useful Patterns</h2>
<pre>
# Process files safely (handles spaces in filenames — use find + xargs -0)
find /path -name "*.txt" -print0 | xargs -0 wc -l

# Check if a command exists before using it
if ! command -v jq &>/dev/null; then
    echo "jq is required but not installed" >&2
    exit 1
fi

# Prevent a script from running twice simultaneously
LOCKFILE="/tmp/myscript.lock"
if ! mkdir "$LOCKFILE" 2>/dev/null; then
    echo "Script already running" >&2
    exit 1
fi
trap "rmdir $LOCKFILE" EXIT     # clean up lock on exit

# Parse simple arguments
VERBOSE=false
DRY_RUN=false
while [[ "$#" -gt 0 ]]; do
    case "$1" in
        -v|--verbose) VERBOSE=true ;;
        -n|--dry-run) DRY_RUN=true ;;
        *) echo "Unknown option: $1" >&2; exit 1 ;;
    esac
    shift
done
</pre>

<h2>References</h2>
<ul>
  <li><a href="https://mywiki.wooledge.org/BashGuide" target="_blank" rel="noopener">BashGuide — wooledge.org ↗</a></li>
  <li><a href="https://www.shellcheck.net" target="_blank" rel="noopener">ShellCheck — online linter for shell scripts ↗</a></li>
  <li><a href="https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html" target="_blank" rel="noopener">POSIX Shell Command Language ↗</a></li>
</ul>
