Learn-skills.dev shell-scripting
Shell scripting and bash automation. Use when user asks to "write a bash script", "create a shell script", "parse command line args", "write a deploy script", "automate with bash", "process files with bash", "create an install script", "write a backup script", "handle signals in bash", "parse CSV in bash", "error handling in bash", "functions in bash", "arrays in bash", "string manipulation", "loop patterns", or mentions shell scripting, bash scripting, POSIX shell, script automation, bash best practices, or shell utilities.
git clone https://github.com/NeverSight/learn-skills.dev
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/1mangesh1/dev-skills-collection/shell-scripting" ~/.claude/skills/neversight-learn-skills-dev-shell-scripting && rm -rf "$T"
data/skills-md/1mangesh1/dev-skills-collection/shell-scripting/SKILL.mdShell Scripting & Bash Best Practices
Comprehensive guide to writing robust, portable, and maintainable shell scripts. Covers Bash idioms, POSIX compliance, error handling, security, and real-world patterns.
Bash Script Template
Every script should start with a solid foundation.
#!/usr/bin/env bash # # script-name.sh - Brief description of what the script does # # Usage: script-name.sh [OPTIONS] <arguments> # # Author: Your Name # Date: 2024-01-01 set -euo pipefail IFS=$'\n\t' # --- Constants ---------------------------------------------------------------- readonly SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" readonly VERSION="1.0.0" # --- Cleanup Trap ------------------------------------------------------------- cleanup() { local exit_code=$? # Remove temp files, release locks, etc. if [[ -n "${TMPDIR_CUSTOM:-}" && -d "$TMPDIR_CUSTOM" ]]; then rm -rf "$TMPDIR_CUSTOM" fi exit "$exit_code" } trap cleanup EXIT trap 'echo "Interrupted."; exit 130' INT trap 'echo "Terminated."; exit 143' TERM # --- Main Logic --------------------------------------------------------------- main() { parse_args "$@" validate_dependencies # ... your logic here ... } main "$@"
What the options mean
-- Exit immediately on any command failure.set -e
-- Treat unset variables as an error.set -u
-- A pipeline fails if any command in it fails, not just the last.set -o pipefail
-- Safer word splitting; avoids problems with spaces in filenames.IFS=$'\n\t'
Variable Handling
Quoting Rules
Always double-quote variables unless you explicitly need word splitting or globbing.
# CORRECT -- variables are quoted name="world" echo "Hello, $name" cp "$source" "$destination" # WRONG -- unquoted variables break on spaces cp $source $destination # Breaks if paths have spaces # When you DO want globbing (intentionally) for f in *.txt; do echo "Processing: $f" done
Variable Expansion and Defaults
# Default value if unset or empty db_host="${DB_HOST:-localhost}" db_port="${DB_PORT:-5432}" # Assign default if unset or empty : "${LOG_LEVEL:=info}" # Error if variable is unset : "${API_KEY:?ERROR: API_KEY must be set}" # Substring extraction filename="report-2024-01-15.csv" echo "${filename:0:6}" # "report" echo "${filename: -3}" # "csv" (note the space before -) # String length echo "${#filename}" # 22 # Variable indirection var_name="HOME" echo "${!var_name}" # prints value of $HOME
Removal and Replacement
filepath="/home/user/documents/report.tar.gz" # Remove shortest match from front echo "${filepath#*/}" # "home/user/documents/report.tar.gz" # Remove longest match from front echo "${filepath##*/}" # "report.tar.gz" (basename) # Remove shortest match from end echo "${filepath%.*}" # "/home/user/documents/report.tar" # Remove longest match from end echo "${filepath%%.*}" # "/home/user/documents/report" # Pattern substitution echo "${filepath/user/admin}" # "/home/admin/documents/report.tar.gz" # Replace all occurrences msg="foo-bar-baz" echo "${msg//-/_}" # "foo_bar_baz" # Case conversion (Bash 4+) text="Hello World" echo "${text,,}" # "hello world" (lowercase) echo "${text^^}" # "HELLO WORLD" (uppercase) echo "${text~}" # "hELLO WORLD" (toggle first char)
Conditionals and Test Operators
if/elif/else
if [[ -f "$config_file" ]]; then source "$config_file" elif [[ -f /etc/default/myapp ]]; then source /etc/default/myapp else echo "No configuration found, using defaults." fi
Test Operators
# File tests [[ -e "$path" ]] # Exists (file, directory, symlink, etc.) [[ -f "$path" ]] # Regular file [[ -d "$path" ]] # Directory [[ -L "$path" ]] # Symlink [[ -r "$path" ]] # Readable [[ -w "$path" ]] # Writable [[ -x "$path" ]] # Executable [[ -s "$path" ]] # Non-empty file [[ "$a" -nt "$b" ]] # a is newer than b [[ "$a" -ot "$b" ]] # a is older than b # String tests [[ -z "$str" ]] # Empty string [[ -n "$str" ]] # Non-empty string [[ "$a" == "$b" ]] # String equality [[ "$a" != "$b" ]] # String inequality [[ "$a" == *.txt ]] # Glob pattern match [[ "$a" =~ ^[0-9]+$ ]] # Regex match # Numeric comparisons [[ "$x" -eq "$y" ]] # Equal [[ "$x" -ne "$y" ]] # Not equal [[ "$x" -lt "$y" ]] # Less than [[ "$x" -gt "$y" ]] # Greater than [[ "$x" -le "$y" ]] # Less than or equal [[ "$x" -ge "$y" ]] # Greater than or equal # Logical operators inside [[ ]] [[ -f "$f" && -r "$f" ]] # AND [[ -f "$f" || -d "$f" ]] # OR [[ ! -e "$path" ]] # NOT
Arithmetic
# Arithmetic evaluation (( count++ )) (( total = price * quantity )) if (( age >= 18 )); then echo "Adult" fi # Ternary-style (( result = (a > b) ? a : b ))
Loops
for loops
# Iterate over a list for fruit in apple banana cherry; do echo "Fruit: $fruit" done # C-style for loop for (( i = 0; i < 10; i++ )); do echo "Iteration $i" done # Iterate over files safely for file in /var/log/*.log; do [[ -f "$file" ]] || continue # Guard against no matches echo "Log: $file" done # Iterate over command output (line by line) while IFS= read -r line; do echo "Line: $line" done < <(find /tmp -maxdepth 1 -name "*.tmp" -type f) # Iterate over array declare -a servers=("web01" "web02" "db01") for server in "${servers[@]}"; do echo "Pinging $server..." done
while and until
# while loop counter=0 while (( counter < 5 )); do echo "Count: $counter" (( counter++ )) done # Read file line by line while IFS= read -r line; do echo ">> $line" done < "$input_file" # Read with a custom delimiter (e.g., colon-separated) while IFS=: read -r user _ uid gid _ home shell; do echo "User: $user, Home: $home, Shell: $shell" done < /etc/passwd # until loop (runs until condition becomes true) until ping -c1 -W1 "$host" &>/dev/null; do echo "Waiting for $host to come online..." sleep 5 done echo "$host is reachable."
Loop Control
for i in {1..100}; do (( i % 2 == 0 )) && continue # Skip even numbers (( i > 20 )) && break # Stop after 20 echo "$i" done
Functions and Return Values
# Function definition log() { local level="$1" shift local message="$*" local timestamp timestamp="$(date '+%Y-%m-%d %H:%M:%S')" printf '[%s] [%-5s] %s\n' "$timestamp" "$level" "$message" } # Using local variables (always use local in functions) calculate_sum() { local -i a="$1" local -i b="$2" local -i result result=$(( a + b )) echo "$result" # Return value via stdout } sum=$(calculate_sum 10 20) echo "Sum: $sum" # "Sum: 30" # Return codes for success/failure signaling is_valid_ip() { local ip="$1" local regex='^([0-9]{1,3}\.){3}[0-9]{1,3}$' if [[ "$ip" =~ $regex ]]; then return 0 # success else return 1 # failure fi } if is_valid_ip "192.168.1.1"; then echo "Valid IP" fi # Function with nameref (Bash 4.3+) get_result() { local -n ref="$1" ref="computed value" } get_result my_var echo "$my_var" # "computed value"
Command-Line Argument Parsing
Manual Parsing (Flexible, handles long options)
usage() { cat <<USAGE Usage: ${SCRIPT_NAME} [OPTIONS] <input-file> Options: -o, --output FILE Output file (default: stdout) -v, --verbose Enable verbose output -n, --dry-run Show what would be done -h, --help Show this help message --version Show version Examples: ${SCRIPT_NAME} -v --output result.txt data.csv ${SCRIPT_NAME} --dry-run input.log USAGE exit "${1:-0}" } # Defaults output="" verbose=false dry_run=false input_file="" while [[ $# -gt 0 ]]; do case "$1" in -o|--output) [[ -n "${2:-}" ]] || { echo "Error: --output requires a value"; usage 1; } output="$2" shift 2 ;; -v|--verbose) verbose=true shift ;; -n|--dry-run) dry_run=true shift ;; -h|--help) usage 0 ;; --version) echo "${SCRIPT_NAME} v${VERSION}" exit 0 ;; --) shift break ;; -*) echo "Error: Unknown option: $1" >&2 usage 1 ;; *) input_file="$1" shift ;; esac done # Remaining positional arguments after -- [[ -n "$input_file" ]] || { echo "Error: input file required" >&2; usage 1; }
getopts (POSIX compatible, short options only)
verbose=0 output="" while getopts ":vo:h" opt; do case "$opt" in v) verbose=1 ;; o) output="$OPTARG" ;; h) usage 0 ;; :) echo "Error: -${OPTARG} requires an argument" >&2; exit 1 ;; *) echo "Error: Unknown option -${OPTARG}" >&2; exit 1 ;; esac done shift $((OPTIND - 1))
Input/Output Redirection and Pipes
# Standard redirections command > file.txt # Redirect stdout (overwrite) command >> file.txt # Redirect stdout (append) command 2> errors.log # Redirect stderr command &> all.log # Redirect both stdout and stderr command > out.log 2>&1 # Same as above (POSIX compatible) command 2>/dev/null # Discard stderr # Redirect both independently command > stdout.log 2> stderr.log # Here document cat <<EOF > /etc/myapp.conf # Configuration generated on $(date) server_name=${SERVER_NAME} port=${PORT:-8080} EOF # Here document without variable expansion (note the quotes) cat <<'EOF' > script_template.sh #!/bin/bash echo "This $variable is literal, not expanded" EOF # Here string grep "error" <<< "$log_contents" # Process substitution diff <(sort file1.txt) <(sort file2.txt) # Pipeline with error checking set -o pipefail cat access.log | grep "500" | awk '{print $1}' | sort -u > failed_ips.txt # tee -- write to file and stdout command | tee output.log # Display and save command | tee -a output.log # Display and append command 2>&1 | tee debug.log # Capture everything # File descriptor manipulation exec 3> custom_output.log # Open fd 3 for writing echo "Custom log entry" >&3 exec 3>&- # Close fd 3
Process Management
# Run in background long_running_task & pid=$! echo "Started background task with PID: $pid" # Wait for specific process wait "$pid" echo "Task exited with status: $?" # Wait for all background jobs job1 & job2 & job3 & wait # Wait for all # Parallel execution with controlled concurrency max_jobs=4 for file in /data/*.csv; do while (( $(jobs -r | wc -l) >= max_jobs )); do sleep 0.5 done process_file "$file" & done wait # Trap signals shutdown() { echo "Shutting down gracefully..." # Kill child processes kill -- -$$ 2>/dev/null || true exit 0 } trap shutdown SIGINT SIGTERM # PID file for singleton enforcement acquire_lock() { local pidfile="$1" if [[ -f "$pidfile" ]]; then local old_pid old_pid="$(cat "$pidfile")" if kill -0 "$old_pid" 2>/dev/null; then echo "Error: Already running (PID $old_pid)" >&2 return 1 fi echo "Removing stale PID file" >&2 fi echo $$ > "$pidfile" } release_lock() { local pidfile="$1" rm -f "$pidfile" } # Timeout a command timeout 30 long_running_command || { echo "Command timed out after 30 seconds" exit 1 }
String Manipulation with Parameter Expansion
No need for
sed or awk for simple string operations.
str=" Hello, World! " # Trim leading/trailing whitespace (Bash trick) trimmed="${str#"${str%%[![:space:]]*}"}" trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # Check if string contains substring if [[ "$str" == *"World"* ]]; then echo "Contains 'World'" fi # Split string into array IFS=',' read -ra parts <<< "one,two,three,four" for part in "${parts[@]}"; do echo "Part: $part" done # Join array into string join_by() { local IFS="$1" shift echo "$*" } result=$(join_by ',' "${parts[@]}") echo "$result" # "one,two,three,four" # Repeat a character printf '=%.0s' {1..60} echo # Uppercase / lowercase first character name="john" echo "${name^}" # "John" name="JOHN" echo "${name,}" # "jOHN"
Array Handling
# Indexed arrays declare -a fruits=("apple" "banana" "cherry") fruits+=("date") # Append echo "${fruits[0]}" # First element echo "${fruits[@]}" # All elements echo "${#fruits[@]}" # Length echo "${!fruits[@]}" # All indices # Slice echo "${fruits[@]:1:2}" # "banana cherry" # Remove element (leaves gap) unset 'fruits[1]' # Iterate for fruit in "${fruits[@]}"; do echo "$fruit" done # Associative arrays (Bash 4+) declare -A config config[host]="localhost" config[port]="8080" config[debug]="true" # Check if key exists if [[ -v config[host] ]]; then echo "Host: ${config[host]}" fi # Iterate keys and values for key in "${!config[@]}"; do echo "$key = ${config[$key]}" done # Array from command output mapfile -t lines < <(ls -1 /tmp) echo "Found ${#lines[@]} items in /tmp" # Array filtering declare -a evens=() for n in {1..20}; do (( n % 2 == 0 )) && evens+=("$n") done echo "Evens: ${evens[*]}"
Error Handling Patterns
# Custom error handler err_handler() { local line_no="$1" local command="$2" local exit_code="$3" echo "ERROR: Command '${command}' failed at line ${line_no} with exit code ${exit_code}" >&2 } trap 'err_handler ${LINENO} "${BASH_COMMAND}" $?' ERR # die function for fatal errors die() { echo "FATAL: $*" >&2 exit 1 } # Retry with exponential backoff retry() { local max_attempts="${1:-3}" local delay="${2:-1}" shift 2 local attempt=1 until "$@"; do if (( attempt >= max_attempts )); then echo "Command failed after $max_attempts attempts: $*" >&2 return 1 fi echo "Attempt $attempt failed. Retrying in ${delay}s..." >&2 sleep "$delay" (( attempt++ )) (( delay *= 2 )) done } # Usage: retry 5 2 curl -sf https://example.com/health # Require commands to exist require_cmd() { for cmd in "$@"; do command -v "$cmd" >/dev/null 2>&1 || die "Required command not found: $cmd" done } require_cmd git curl jq # Assert function assert() { local description="$1" shift if ! "$@"; then die "Assertion failed: $description" fi } assert "Config file exists" test -f /etc/myapp.conf
File Operations
# Safe temporary files tmpfile="$(mktemp)" tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpfile" "$tmpdir"' EXIT # Find files with various criteria find /var/log -name "*.log" -mtime +30 -type f -delete # Delete logs older than 30 days find . -name "*.sh" -exec chmod +x {} + # Make all .sh files executable find . -type f -size +100M # Find files over 100MB # Portable file reading while IFS= read -r line || [[ -n "$line" ]]; do echo "$line" done < "$file" # Note: || [[ -n "$line" ]] handles files without trailing newline # Atomic file write (write to temp, then move) atomic_write() { local target="$1" local tmp tmp="$(mktemp "${target}.XXXXXX")" if cat > "$tmp" && mv -f "$tmp" "$target"; then return 0 else rm -f "$tmp" return 1 fi } echo "new content" | atomic_write /etc/myapp.conf # Check and create directory ensure_dir() { local dir="$1" if [[ ! -d "$dir" ]]; then mkdir -p "$dir" || die "Cannot create directory: $dir" fi } # Compare files if cmp -s file1.txt file2.txt; then echo "Files are identical" else echo "Files differ" fi # Basename and dirname without external commands path="/home/user/docs/report.pdf" echo "${path##*/}" # "report.pdf" (basename) echo "${path%/*}" # "/home/user/docs" (dirname)
Portable Scripting (POSIX Compliance)
# Use #!/bin/sh for POSIX scripts, #!/usr/bin/env bash for Bash scripts # POSIX-compatible alternatives: # Instead of [[ ]], use [ ] with proper quoting if [ -f "$file" ] && [ -r "$file" ]; then echo "File exists and is readable" fi # Instead of (( )), use [ ] with -eq, -lt, etc. if [ "$count" -gt 10 ]; then echo "Count exceeds 10" fi # Instead of $() for arithmetic, use expr or $(( )) total=$((a + b)) # Instead of arrays (not POSIX), use positional parameters or IFS splitting # Instead of local (not strictly POSIX), most shells support it anyway # Instead of Bash-specific string manipulation, use cut, sed, or tr # Bash: echo "${var,,}" # POSIX: echo "$var" | tr '[:upper:]' '[:lower:]' # Use printf instead of echo -e (echo behavior varies across shells) printf 'Line 1\nLine 2\n' # Check your scripts with shellcheck # shellcheck disable=SC2034 -- Inline suppression # Run: shellcheck -s bash script.sh
Common Patterns
Lockfile Pattern
LOCKFILE="/var/run/${SCRIPT_NAME}.lock" acquire_lock() { if ( set -o noclobber; echo $$ > "$LOCKFILE" ) 2>/dev/null; then trap 'rm -f "$LOCKFILE"' EXIT return 0 fi local lock_pid lock_pid="$(cat "$LOCKFILE" 2>/dev/null)" if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then echo "Script is already running (PID $lock_pid)" >&2 return 1 fi echo "Removing stale lock file" >&2 rm -f "$LOCKFILE" acquire_lock }
Configuration File Parsing
# Parse a simple key=value config file declare -A CONFIG parse_config() { local config_file="$1" [[ -f "$config_file" ]] || die "Config file not found: $config_file" while IFS= read -r line || [[ -n "$line" ]]; do # Skip blank lines and comments [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue # Extract key and value local key="${line%%=*}" local value="${line#*=}" # Trim whitespace key="${key#"${key%%[![:space:]]*}"}" key="${key%"${key##*[![:space:]]}"}" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" # Remove surrounding quotes from value value="${value#\"}" value="${value%\"}" CONFIG["$key"]="$value" done < "$config_file" } parse_config /etc/myapp.conf echo "DB host: ${CONFIG[db_host]:-localhost}"
Logging Framework
LOG_LEVEL="${LOG_LEVEL:-INFO}" LOG_FILE="${LOG_FILE:-/var/log/myapp.log}" declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3 [FATAL]=4) log() { local level="$1" shift local message="$*" local current_level="${LOG_LEVELS[${LOG_LEVEL}]:-1}" local msg_level="${LOG_LEVELS[${level}]:-1}" (( msg_level < current_level )) && return local timestamp timestamp="$(date '+%Y-%m-%d %H:%M:%S')" local entry entry="$(printf '[%s] [%-5s] [%s:%s] %s' "$timestamp" "$level" "${FUNCNAME[1]:-main}" "${BASH_LINENO[0]}" "$message")" echo "$entry" >> "$LOG_FILE" if [[ "$level" == "ERROR" || "$level" == "FATAL" ]]; then echo "$entry" >&2 fi } log INFO "Application started" log DEBUG "Verbose debugging info" log ERROR "Something went wrong"
Here Documents and Here Strings
# Here document with variable expansion generate_html() { local title="$1" local body="$2" cat <<-EOF <!DOCTYPE html> <html> <head><title>${title}</title></head> <body>${body}</body> </html> EOF } # Here document passed to a command's stdin mysql -u root <<SQL CREATE DATABASE IF NOT EXISTS myapp; GRANT ALL ON myapp.* TO 'appuser'@'localhost'; SQL # Here string (Bash extension) while IFS=, read -r name age city; do echo "Name: $name, Age: $age, City: $city" done <<< "Alice,30,NYC Bob,25,LA Charlie,35,Chicago" # Indent-stripped here doc (use <<- with tabs) if true; then cat <<-'USAGE' Usage: command [options] -h Show help -v Verbose mode USAGE fi
Security Best Practices
# NEVER use eval with user input # BAD: eval "$user_input" # BAD: eval "echo $untrusted" # GOOD: Use arrays and direct execution # Quote EVERYTHING rm "$file" # GOOD rm $file # BAD -- breaks on spaces, globs could expand # Validate inputs validate_filename() { local name="$1" if [[ "$name" =~ [^a-zA-Z0-9._-] ]]; then die "Invalid filename: $name (contains special characters)" fi if [[ "$name" == ..* || "$name" == */* ]]; then die "Invalid filename: $name (path traversal attempt)" fi } # Use -- to end option parsing (prevents option injection) rm -- "$file" grep -- "$pattern" "$file" # Restrict PATH export PATH="/usr/local/bin:/usr/bin:/bin" # Use secure temp files tmpfile="$(mktemp)" || die "Failed to create temp file" chmod 600 "$tmpfile" # Avoid writing secrets to the command line (visible in ps) # BAD: mysql -p"$password" ... # GOOD: Use environment variables or config files export MYSQL_PWD="$password" mysql -u root mydb # Do not store secrets in shell variables that get exported # If you must, unset them after use unset MYSQL_PWD # Prevent glob expansion when not needed set -f # Disable globbing # ... process user input ... set +f # Re-enable globbing # Drop privileges when running as root if [[ "$(id -u)" -eq 0 ]]; then exec su -s /bin/bash nobody -- "$0" "$@" fi
Useful One-Liners and Idioms
# Check if running as root (( EUID == 0 )) || die "Must run as root" # Check if a command exists command -v docker >/dev/null 2>&1 || die "Docker is not installed" # Portable way to get the script's directory SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Default variable using :- vs - # ${var:-default} uses default if var is unset OR empty # ${var-default} uses default only if var is unset # Read password without echoing read -rsp "Enter password: " password echo # Confirm before proceeding confirm() { read -rp "${1:-Are you sure?} [y/N] " response [[ "$response" =~ ^[Yy]$ ]] } confirm "Delete all files?" || exit 0 # Progress indicator spin() { local -a frames=('|' '/' '-' '\') while true; do for frame in "${frames[@]}"; do printf '\r%s %s' "$frame" "$1" sleep 0.2 done done } spin "Working..." & spinner_pid=$! # ... do work ... kill "$spinner_pid" 2>/dev/null printf '\rDone. \n' # Measure execution time start_time="$(date +%s)" # ... do work ... end_time="$(date +%s)" echo "Elapsed: $(( end_time - start_time )) seconds" # Generate random string random_string=$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 16) # Check if stdin is a terminal if [[ -t 0 ]]; then echo "Interactive mode" else echo "Reading from pipe or file" fi # Coalesce empty values result="${value1:-${value2:-${value3:-fallback}}}"
Script Debugging
# Enable debug tracing set -x # Print each command before execution set +x # Disable tracing # Custom trace prompt for better readability export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:+${FUNCNAME[0]}():} ' # Debug only a section debug_section() { set -x # ... commands to debug ... set +x } # Conditional debugging via environment variable if [[ "${DEBUG:-}" == "true" ]]; then set -x fi # Debug function that respects verbosity debug() { [[ "${VERBOSE:-false}" == "true" ]] && echo "DEBUG: $*" >&2 } # Trace function calls trace_calls() { echo "TRACE: ${FUNCNAME[1]} called from ${FUNCNAME[2]:-main} (line ${BASH_LINENO[1]})" >&2 } # Dump all variables (useful for debugging) dump_vars() { echo "=== Variable Dump ===" >&2 declare -p 2>/dev/null | grep -v ' -[aAirx]' >&2 echo "=== End Dump ===" >&2 } # Run script in debug mode from the command line: # bash -x script.sh # bash -xv script.sh (also shows the script lines being read)
Complete Example: Backup Script
#!/usr/bin/env bash # # backup.sh - Incremental backup script with rotation # set -euo pipefail IFS=$'\n\t' readonly SCRIPT_NAME="$(basename "$0")" readonly VERSION="1.0.0" readonly DEFAULT_RETENTION=7 # --- Logging ------------------------------------------------------------------ log() { printf '[%s] [%-5s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "${*:2}"; } info() { log INFO "$@"; } warn() { log WARN "$@"; } error() { log ERROR "$@" >&2; } die() { error "$@"; exit 1; } # --- Cleanup ------------------------------------------------------------------ cleanup() { local ec=$? [[ -n "${tmpdir:-}" ]] && rm -rf "$tmpdir" (( ec != 0 )) && error "Backup failed with exit code $ec" exit "$ec" } trap cleanup EXIT # --- Usage -------------------------------------------------------------------- usage() { cat <<HELP Usage: ${SCRIPT_NAME} [OPTIONS] <source-directory> Creates a compressed, timestamped backup of the given directory. Options: -d, --dest DIR Destination directory (default: /backups) -r, --retention DAYS Delete backups older than DAYS (default: ${DEFAULT_RETENTION}) -n, --dry-run Show what would be done -v, --verbose Verbose output -h, --help Show this help --version Show version Examples: ${SCRIPT_NAME} /etc ${SCRIPT_NAME} -d /mnt/nas/backups -r 30 /var/www HELP exit "${1:-0}" } # --- Parse Arguments ---------------------------------------------------------- dest="/backups" retention="$DEFAULT_RETENTION" dry_run=false verbose=false source_dir="" while [[ $# -gt 0 ]]; do case "$1" in -d|--dest) dest="${2:?--dest requires a value}"; shift 2 ;; -r|--retention) retention="${2:?--retention requires a value}"; shift 2 ;; -n|--dry-run) dry_run=true; shift ;; -v|--verbose) verbose=true; shift ;; -h|--help) usage 0 ;; --version) echo "${SCRIPT_NAME} v${VERSION}"; exit 0 ;; --) shift; break ;; -*) die "Unknown option: $1" ;; *) source_dir="$1"; shift ;; esac done [[ -n "$source_dir" ]] || { error "Source directory required"; usage 1; } [[ -d "$source_dir" ]] || die "Source is not a directory: $source_dir" command -v tar >/dev/null || die "tar is required but not found" # --- Main Logic --------------------------------------------------------------- main() { local timestamp timestamp="$(date '+%Y%m%d-%H%M%S')" local archive_name archive_name="backup-$(basename "$source_dir")-${timestamp}.tar.gz" local archive_path="${dest}/${archive_name}" info "Backing up: $source_dir -> $archive_path" if "$dry_run"; then info "[DRY RUN] Would create: $archive_path" info "[DRY RUN] Would remove backups older than $retention days" return 0 fi mkdir -p "$dest" tmpdir="$(mktemp -d)" local tmp_archive="${tmpdir}/${archive_name}" tar -czf "$tmp_archive" -C "$(dirname "$source_dir")" "$(basename "$source_dir")" mv "$tmp_archive" "$archive_path" chmod 600 "$archive_path" local size size="$(du -sh "$archive_path" | cut -f1)" info "Backup complete: $archive_path ($size)" # Rotate old backups local deleted=0 while IFS= read -r old_backup; do rm -f "$old_backup" (( deleted++ )) "$verbose" && info "Deleted old backup: $old_backup" done < <(find "$dest" -name "backup-$(basename "$source_dir")-*.tar.gz" -mtime "+${retention}" -type f) (( deleted > 0 )) && info "Removed $deleted old backup(s)" info "Done." } main