| name | shell-scripting |
| description | 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. |
| license | MIT |
| metadata | {"author":"1mangesh1","version":"1.0.0","tags":["bash","shell","scripting","automation","posix","cli"]} |
Shell 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
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly VERSION="1.0.0"
cleanup() {
local exit_code=$?
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() {
parse_args "$@"
validate_dependencies
}
main "$@"
What the options mean
set -e -- Exit immediately on any command failure.
set -u -- Treat unset variables as an error.
set -o pipefail -- A pipeline fails if any command in it fails, not just the last.
IFS=$'\n\t' -- Safer word splitting; avoids problems with spaces in filenames.
Variable Handling
Quoting Rules
Always double-quote variables unless you explicitly need word splitting or globbing.
name="world"
echo "Hello, $name"
cp "$source" "$destination"
cp $source $destination
for f in *.txt; do
echo "Processing: $f"
done
Variable Expansion and Defaults
db_host="${DB_HOST:-localhost}"
db_port="${DB_PORT:-5432}"
: "${LOG_LEVEL:=info}"
: "${API_KEY:?ERROR: API_KEY must be set}"
filename="report-2024-01-15.csv"
echo "${filename:0:6}"
echo "${filename: -3}"
echo "${#filename}"
var_name="HOME"
echo "${!var_name}"
Removal and Replacement
filepath="/home/user/documents/report.tar.gz"
echo "${filepath#*/}"
echo "${filepath##*/}"
echo "${filepath%.*}"
echo "${filepath%%.*}"
echo "${filepath/user/admin}"
msg="foo-bar-baz"
echo "${msg//-/_}"
text="Hello World"
echo "${text,,}"
echo "${text^^}"
echo "${text~}"
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
[[ -e "$path" ]]
[[ -f "$path" ]]
[[ -d "$path" ]]
[[ -L "$path" ]]
[[ -r "$path" ]]
[[ -w "$path" ]]
[[ -x "$path" ]]
[[ -s "$path" ]]
[[ "$a" -nt "$b" ]]
[[ "$a" -ot "$b" ]]
[[ -z "$str" ]]
[[ -n "$str" ]]
[[ "$a" == "$b" ]]
[[ "$a" != "$b" ]]
[[ "$a" == *.txt ]]
[[ "$a" =~ ^[0-9]+$ ]]
[[ "$x" -eq "$y" ]]
[[ "$x" -ne "$y" ]]
[[ "$x" -lt "$y" ]]
[[ "$x" -gt "$y" ]]
[[ "$x" -le "$y" ]]
[[ "$x" -ge "$y" ]]
[[ -f "$f" && -r "$f" ]]
[[ -f "$f" || -d "$f" ]]
[[ ! -e "$path" ]]
Arithmetic
(( count++ ))
(( total = price * quantity ))
if (( age >= 18 )); then
echo "Adult"
fi
(( result = (a > b) ? a : b ))
Loops
for loops
for fruit in apple banana cherry; do
echo "Fruit: $fruit"
done
for (( i = 0; i < 10; i++ )); do
echo "Iteration $i"
done
for file in /var/log/*.log; do
[[ -f "$file" ]] || continue
echo "Log: $file"
done
while IFS= read -r line; do
echo "Line: $line"
done < <(find /tmp -maxdepth 1 -name "*.tmp" -type f)
declare -a servers=("web01" "web02" "db01")
for server in "${servers[@]}"; do
echo "Pinging $server..."
done
while and until
counter=0
while (( counter < 5 )); do
echo "Count: $counter"
(( counter++ ))
done
while IFS= read -r line; do
echo ">> $line"
done < "$input_file"
while IFS=: read -r user _ uid gid _ home shell; do
echo "User: $user, Home: $home, Shell: $shell"
done < /etc/passwd
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
(( i > 20 )) && break
echo "$i"
done
Functions and Return Values
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"
}
calculate_sum() {
local -i a="$1"
local -i b="$2"
local -i result
result=$(( a + b ))
echo "$result"
}
sum=$(calculate_sum 10 20)
echo "Sum: $sum"
is_valid_ip() {
local ip="$1"
local regex='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
if [[ "$ip" =~ $regex ]]; then
return 0
else
return 1
fi
}
if is_valid_ip "192.168.1.1"; then
echo "Valid IP"
fi
get_result() {
local -n ref="$1"
ref="computed value"
}
get_result my_var
echo "$my_var"
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}"
}
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
[[ -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
command > file.txt
command >> file.txt
command 2> errors.log
command &> all.log
command > out.log 2>&1
command 2>/dev/null
command > stdout.log 2> stderr.log
cat <<EOF > /etc/myapp.conf
# Configuration generated on $(date)
server_name=${SERVER_NAME}
port=${PORT:-8080}
EOF
cat <<'EOF' > script_template.sh
echo "This $variable is literal, not expanded"
EOF
grep "error" <<< "$log_contents"
diff <(sort file1.txt) <(sort file2.txt)
set -o pipefail
cat access.log | grep "500" | awk '{print $1}' | sort -u > failed_ips.txt
command | tee output.log
command | tee -a output.log
command 2>&1 | tee debug.log
exec 3> custom_output.log
echo "Custom log entry" >&3
exec 3>&-
Process Management
long_running_task &
pid=$!
echo "Started background task with PID: $pid"
wait "$pid"
echo "Task exited with status: $?"
job1 &
job2 &
job3 &
wait
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
shutdown() {
echo "Shutting down gracefully..."
kill -- -$$ 2>/dev/null || true
exit 0
}
trap shutdown SIGINT SIGTERM
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 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! "
trimmed="${str#"${str%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
if [[ "$str" == *"World"* ]]; then
echo "Contains 'World'"
fi
IFS=',' read -ra parts <<< "one,two,three,four"
for part in "${parts[@]}"; do
echo "Part: $part"
done
join_by() {
local IFS="$1"
shift
echo "$*"
}
result=$(join_by ',' "${parts[@]}")
echo "$result"
printf '=%.0s' {1..60}
echo
name="john"
echo "${name^}"
name="JOHN"
echo "${name,}"
Array Handling
declare -a fruits=("apple" "banana" "cherry")
fruits+=("date")
echo "${fruits[0]}"
echo "${fruits[@]}"
echo "${#fruits[@]}"
echo "${!fruits[@]}"
echo "${fruits[@]:1:2}"
unset 'fruits[1]'
for fruit in "${fruits[@]}"; do
echo "$fruit"
done
declare -A config
config[host]="localhost"
config[port]="8080"
config[debug]="true"
if [[ -v config[host] ]]; then
echo "Host: ${config[host]}"
fi
for key in "${!config[@]}"; do
echo "$key = ${config[$key]}"
done
mapfile -t lines < <(ls -1 /tmp)
echo "Found ${#lines[@]} items in /tmp"
declare -a evens=()
for n in {1..20}; do
(( n % 2 == 0 )) && evens+=("$n")
done
echo "Evens: ${evens[*]}"
Error Handling Patterns
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() {
echo "FATAL: $*" >&2
exit 1
}
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
}
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() {
local description="$1"
shift
if ! "$@"; then
die "Assertion failed: $description"
fi
}
assert "Config file exists" test -f /etc/myapp.conf
File Operations
tmpfile="$(mktemp)"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpfile" "$tmpdir"' EXIT
find /var/log -name "*.log" -mtime +30 -type f -delete
find . -name "*.sh" -exec chmod +x {} +
find . -type f -size +100M
while IFS= read -r line || [[ -n "$line" ]]; do
echo "$line"
done < "$file"
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
ensure_dir() {
local dir="$1"
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir" || die "Cannot create directory: $dir"
fi
}
if cmp -s file1.txt file2.txt; then
echo "Files are identical"
else
echo "Files differ"
fi
path="/home/user/docs/report.pdf"
echo "${path##*/}"
echo "${path%/*}"
Portable Scripting (POSIX Compliance)
if [ -f "$file" ] && [ -r "$file" ]; then
echo "File exists and is readable"
fi
if [ "$count" -gt 10 ]; then
echo "Count exceeds 10"
fi
total=$((a + b))
printf 'Line 1\nLine 2\n'
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
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
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
local key="${line%%=*}"
local value="${line#*=}"
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
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
generate_html() {
local title="$1"
local body="$2"
cat <<-EOF
<!DOCTYPE html>
<html>
<head><title>${title}</title></head>
<body>${body}</body>
</html>
EOF
}
mysql -u root <<SQL
CREATE DATABASE IF NOT EXISTS myapp;
GRANT ALL ON myapp.* TO 'appuser'@'localhost';
SQL
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"
if true; then
cat <<-'USAGE'
Usage: command [options]
-h Show help
-v Verbose mode
USAGE
fi
Security Best Practices
rm "$file"
rm $file
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
}
rm -- "$file"
grep -- "$pattern" "$file"
export PATH="/usr/local/bin:/usr/bin:/bin"
tmpfile="$(mktemp)" || die "Failed to create temp file"
chmod 600 "$tmpfile"
export MYSQL_PWD="$password"
mysql -u root mydb
unset MYSQL_PWD
set -f
set +f
if [[ "$(id -u)" -eq 0 ]]; then
exec su -s /bin/bash nobody -- "$0" "$@"
fi
Useful One-Liners and Idioms
(( EUID == 0 )) || die "Must run as root"
command -v docker >/dev/null 2>&1 || die "Docker is not installed"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
read -rsp "Enter password: " password
echo
confirm() {
read -rp "${1:-Are you sure?} [y/N] " response
[[ "$response" =~ ^[Yy]$ ]]
}
confirm "Delete all files?" || exit 0
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=$!
kill "$spinner_pid" 2>/dev/null
printf '\rDone. \n'
start_time="$(date +%s)"
end_time="$(date +%s)"
echo "Elapsed: $(( end_time - start_time )) seconds"
random_string=$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 16)
if [[ -t 0 ]]; then
echo "Interactive mode"
else
echo "Reading from pipe or file"
fi
result="${value1:-${value2:-${value3:-fallback}}}"
Script Debugging
set -x
set +x
export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:+${FUNCNAME[0]}():} '
debug_section() {
set -x
set +x
}
if [[ "${DEBUG:-}" == "true" ]]; then
set -x
fi
debug() {
[[ "${VERBOSE:-false}" == "true" ]] && echo "DEBUG: $*" >&2
}
trace_calls() {
echo "TRACE: ${FUNCNAME[1]} called from ${FUNCNAME[2]:-main} (line ${BASH_LINENO[1]})" >&2
}
dump_vars() {
echo "=== Variable Dump ===" >&2
declare -p 2>/dev/null | grep -v ' -[aAirx]' >&2
echo "=== End Dump ===" >&2
}
Complete Example: Backup Script
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME="$(basename "$0")"
readonly VERSION="1.0.0"
readonly DEFAULT_RETENTION=7
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() {
local ec=$?
[[ -n "${tmpdir:-}" ]] && rm -rf "$tmpdir"
(( ec != 0 )) && error "Backup failed with exit code $ec"
exit "$ec"
}
trap cleanup EXIT
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}"
}
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() {
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)"
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
References