#!/usr/bin/env bash #------------------------------------------------------------------------------- ## @file _functions.sh ## @brief Gemeinsame Bash-Basis: .env laden, Optionen, Logging, Usage, Trap ## @author ## @owner ## @version 1.0.0 (07.10.2026) ## ## @usage source "${SCRIPT_DIR}/_functions.sh"; B_OPTS+=(…); b_init "$@"; set -- "${ARGS[@]}" ## @note Nur sourcen, nicht ausführen. Benötigt bash >= 4.4. ## Kopien NICHT lokal ändern – Projektspezifisches gehört in ## _functions.local.sh (wird automatisch geladen, falls vorhanden). ## Standard: ai/common/bash.md #------------------------------------------------------------------------------- if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then echo "_functions.sh wird gesourct, nicht ausgeführt." >&2 exit 2 fi [[ -n "${_B_LOADED:-}" ]] && return 0 if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) )); then echo "FEHLER: bash >= 4.4 nötig (gefunden: ${BASH_VERSION})." >&2 return 1 fi _B_LOADED=1 B_FUNCTIONS_VERSION="1.0.0" # --- Pfade (pfadsicher, unabhängig vom aufrufenden $PWD) ---------------------- # Das Script setzt SCRIPT_PATH/DIR/NAME selbst; hier nur Fallback. : "${SCRIPT_PATH:=$(readlink -f -- "${BASH_SOURCE[1]:-$0}")}" : "${SCRIPT_DIR:=${SCRIPT_PATH%/*}}" : "${SCRIPT_NAME:=${SCRIPT_PATH##*/}}" B_LIB_DIR="$(readlink -f -- "${BASH_SOURCE[0]}")" B_LIB_DIR="${B_LIB_DIR%/*}" B_CALL_DIR="${PWD}" # Verzeichnis, aus dem aufgerufen wurde B_TMP_DIR="" # wird von b_tmpdir gesetzt # --- Standard-Optionen (gelten für jedes Script) ------------------------------ # Format: "kurz|lang|typ[:META]|default|Beschreibung" typ = flag | value # Variable im Script: OPTION_ (Großbuchstaben, '-' → '_'), z. B. OPTION_DRY_RUN B_OPTS_STD=( "h|help|flag|0|diese Hilfe anzeigen" "d|debug|flag|0|ausführliche Ausgabe (Log-Level debug)" "q|quiet|flag|0|keine Konsolenausgabe (Logdatei bleibt)" "n|dry-run|flag|0|nur anzeigen, nichts ändern" "l|logfile|value:DATEI||zusätzlich in DATEI loggen" ) declare -p B_OPTS &>/dev/null || B_OPTS=() # Script/.env hängen mit B_OPTS+=(…) an declare -ga ARGS=() # Positionsargumente nach b_init declare -grA _B_LEVELS=([debug]=10 [info]=20 [warn]=30 [error]=40) declare -gA _B_OPT_SHORT=() _B_OPT_TYPE=() _B_OPT_META=() _B_OPT_DEFAULT=() _B_OPT_DESC=() declare -ga _B_OPT_ORDER=() #=============================================================================== # Ausgabe / Logging #=============================================================================== ## @brief Zentrale Ausgabe auf Konsole und/oder Logdatei, gefiltert nach Level ## @param $1 Level: debug | info | warn | error ## @param $@ Text ## @return 0 ok, 2 unbekanntes Level blog() { local level="${1:-}"; shift || true local n="${_B_LEVELS[$level]:-}" msg="$*" c="" r="" [[ -n "$n" ]] || { printf 'blog: unbekanntes Level "%s"\n' "$level" >&2; return 2; } if (( ${B_LOG_CONSOLE:-1} )) && (( n >= ${_B_LEVELS[${B_LOG_LEVEL:-info}]:-20} )); then if [[ "${B_LOG_COLOR:-auto}" == 1 || ( "${B_LOG_COLOR:-auto}" == auto && -t 2 ) ]]; then r=$'\e[0m' case "$level" in debug) c=$'\e[2m' ;; warn) c=$'\e[33m' ;; error) c=$'\e[31m' ;; esac fi case "$level" in info) printf '%s\n' "$msg" ;; debug) printf '%s[DEBUG] %s%s\n' "$c" "$msg" "$r" >&2 ;; warn) printf '%s[WARN] %s%s\n' "$c" "$msg" "$r" >&2 ;; error) printf '%s[FEHLER] %s%s\n' "$c" "$msg" "$r" >&2 ;; esac fi if [[ -n "${B_LOG_FILE:-}" ]] \ && (( n >= ${_B_LEVELS[${B_LOG_FILE_LEVEL:-${B_LOG_LEVEL:-info}}]:-20} )); then printf '%(%Y-%m-%d %H:%M:%S)T %-5s %s[%d] %s\n' -1 "${level^^}" "$SCRIPT_NAME" "$$" "$msg" \ >> "$B_LOG_FILE" 2>/dev/null || true fi return 0 } ## @brief Ersatz für echo: Info-Meldung über blog ## @param $@ Text ## @return 0 becho() { blog info "$@"; } ## @brief Debug-Meldung (nur bei -d bzw. B_LOG_LEVEL=debug) ## @param $@ Text ## @return 0 bdebug() { blog debug "$@"; } ## @brief Warnung (stderr) ## @param $@ Text ## @return 0 bwarn() { blog warn "$@"; } ## @brief Fehlermeldung (stderr) ## @param $@ Text ## @return 0 berror() { blog error "$@"; } ## @brief Fehlermeldung ausgeben und Script beenden (EXIT-Trap räumt auf) ## @param $1 Text ## @param $2 Exit-Code (Default 1; 2 = Aufruffehler, zeigt Hilfe-Hinweis) ## @return – (beendet das Script) bdie() { local rc="${2:-1}" berror "${1:-Abbruch}" (( rc == 2 )) && blog info "Hilfe: ${SCRIPT_NAME} --help" exit "$rc" } #=============================================================================== # Hilfen für den Script-Alltag #=============================================================================== ## @brief Befehl ausführen – oder bei --dry-run nur anzeigen ## @param $@ Befehl mit Argumenten (kein String, kein eval) ## @return Exit-Code des Befehls, 0 im Dry-Run brun() { if (( ${OPTION_DRY_RUN:-0} )); then becho "[dry-run] $(printf '%q ' "$@")" return 0 fi bdebug "run: $(printf '%q ' "$@")" "$@" } ## @brief Prüft, ob Programme vorhanden sind ## @param $@ Programmnamen ## @return 0 alle da, 1 mindestens eins fehlt (wird gemeldet) b_require() { local cmd rc=0 for cmd in "$@"; do command -v "$cmd" &>/dev/null || { berror "Programm fehlt: $cmd"; rc=1; } done return "$rc" } ## @brief Prüft, ob Variablen gesetzt und nicht leer sind ## @param $@ Variablennamen ## @return 0 alle gesetzt, 1 mindestens eine fehlt (wird gemeldet) b_require_vars() { local v rc=0 for v in "$@"; do [[ -n "${!v:-}" ]] || { berror "Variable nicht gesetzt: $v (in .env oder Umgebung)"; rc=1; } done return "$rc" } ## @brief Legt ein temporäres Verzeichnis an (B_TMP_DIR), wird beim Exit gelöscht ## @return 0 ok, 1 mktemp fehlgeschlagen b_tmpdir() { [[ -n "$B_TMP_DIR" ]] && return 0 B_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/${SCRIPT_NAME%.sh}.XXXXXX")" || return 1 bdebug "Temp-Verzeichnis: $B_TMP_DIR" } ## @brief Macht einen Pfad absolut; relative Pfade gelten relativ zu $2 ## @param $1 Pfad ## @param $2 Basisverzeichnis (Default: SCRIPT_DIR) ## @return 0; Ergebnis auf stdout b_abspath() { local p="$1" base="${2:-$SCRIPT_DIR}" [[ "$p" == /* ]] || p="${base%/}/${p}" printf '%s\n' "$p" } #=============================================================================== # Usage aus der Präambel #=============================================================================== ## @brief Gibt die Präambel des Scripts als Hilfe aus ('## ' wird entfernt, ## die Zeile '## @OPTIONS@' wird durch die Optionsliste ersetzt) ## @return 0 b_usage() { local line part=0 while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ^#-{10,} ]]; then (( ++part >= 2 )) && break continue fi (( part == 1 )) && [[ "$line" == '##'* ]] || continue line="${line#'##'}"; line="${line# }" if [[ "$line" == *'@OPTIONS@'* ]]; then _b_opts_help; else printf '%s\n' "$line"; fi done < "$SCRIPT_PATH" return 0 } ## @brief Optionsliste für die Hilfe (aus B_OPTS_STD + B_OPTS) ## @return 0 _b_opts_help() { local long s left d for long in "${_B_OPT_ORDER[@]}"; do s="${_B_OPT_SHORT[$long]}" left="${s:+-$s | }--${long}" [[ "${_B_OPT_TYPE[$long]}" == value ]] && left+=" ${_B_OPT_META[$long]}" d="${_B_OPT_DEFAULT[$long]}" if [[ "${_B_OPT_TYPE[$long]}" == value && -n "$d" ]]; then printf ' %-28s %s (Default: %s)\n' "$left" "${_B_OPT_DESC[$long]}" "$d" else printf ' %-28s %s\n' "$left" "${_B_OPT_DESC[$long]}" fi done return 0 } #=============================================================================== # Optionen #=============================================================================== ## @brief Variablenname zu einer Langoption: dry-run → OPTION_DRY_RUN ## @param $1 Langname ## @return 0; Name auf stdout _b_optvar() { local n="${1^^}"; printf 'OPTION_%s\n' "${n//-/_}"; } ## @brief Baut die Optionstabellen aus B_OPTS_STD + B_OPTS und prüft sie ## @return 0 ok, 2 Definitionsfehler _b_opts_build() { local def s long type meta deflt desc rest local -A seen_short=() _B_OPT_ORDER=() for def in "${B_OPTS_STD[@]}" "${B_OPTS[@]}"; do IFS='|' read -r s long type deflt desc rest <<< "$def" meta="${type#*:}"; type="${type%%:*}"; [[ "$meta" == "$type" ]] && meta="WERT" if [[ -z "$long" || ! "$long" =~ ^[a-z][a-z0-9-]*$ || ! "$type" =~ ^(flag|value)$ || -n "$rest" ]]; then berror "Ungültige Optionsdefinition: \"$def\" (kurz|lang|flag|value[:META]|default|Text)" return 2 fi if [[ -n "$s" ]]; then [[ -n "${seen_short[$s]:-}" && "${seen_short[$s]}" != "$long" ]] && { berror "Kurzoption -$s doppelt vergeben (${seen_short[$s]}, $long)"; return 2; } seen_short[$s]="$long" fi [[ -n "${_B_OPT_TYPE[$long]:-}" ]] || _B_OPT_ORDER+=("$long") _B_OPT_SHORT[$long]="$s"; _B_OPT_TYPE[$long]="$type"; _B_OPT_META[$long]="$meta" _B_OPT_DEFAULT[$long]="$deflt"; _B_OPT_DESC[$long]="$desc" done return 0 } ## @brief Setzt OPTION_ für eine erkannte Option ## @param $1 Langname ## @param $2 Wert ## @return 0 ok, 2 ungültiger Wert _b_opt_set() { local long="$1" val="$2" var var="$(_b_optvar "$long")" if [[ "${_B_OPT_TYPE[$long]}" == flag && ! "$val" =~ ^[01]$ ]]; then berror "Option --$long erwartet 0 oder 1, nicht \"$val\"" return 2 fi printf -v "$var" '%s' "$val" _B_CLI_SET[$long]=1 return 0 } ## @brief Parst die Kommandozeile → OPTION_* und ARGS (Positionsargumente) ## @param $@ Argumente des Scripts ## @return 0 ok, 2 Aufruffehler _b_opts_parse() { local arg name val s i long ARGS=() while (( $# )); do arg="$1"; shift case "$arg" in --) ARGS+=("$@"); break ;; --?*) name="${arg#--}"; val="" if [[ "$name" == *=* ]]; then val="${name#*=}"; name="${name%%=*}"; fi [[ -n "${_B_OPT_TYPE[$name]:-}" ]] || { berror "Unbekannte Option: --$name"; return 2; } if [[ "${_B_OPT_TYPE[$name]}" == value && "$arg" != *=* ]]; then (( $# )) || { berror "Option --$name braucht einen Wert"; return 2; } val="$1"; shift elif [[ "${_B_OPT_TYPE[$name]}" == flag && "$arg" != *=* ]]; then val=1 fi _b_opt_set "$name" "$val" || return 2 ;; -?*) for (( i = 1; i < ${#arg}; i++ )); do s="${arg:i:1}"; long="" for name in "${_B_OPT_ORDER[@]}"; do [[ "${_B_OPT_SHORT[$name]}" == "$s" ]] && { long="$name"; break; } done [[ -n "$long" ]] || { berror "Unbekannte Option: -$s"; return 2; } if [[ "${_B_OPT_TYPE[$long]}" == value ]]; then val="${arg:i+1}" # -lDATEI oder -l DATEI if [[ -z "$val" ]]; then (( $# )) || { berror "Option -$s braucht einen Wert"; return 2; } val="$1"; shift fi _b_opt_set "$long" "$val" || return 2 break fi _b_opt_set "$long" 1 || return 2 done ;; *) ARGS+=("$arg") ;; esac done return 0 } #=============================================================================== # Trap / Aufräumen #=============================================================================== ## @brief EXIT-Trap: ruft cleanup() des Scripts (falls definiert), löscht B_TMP_DIR ## @return – (beendet mit dem ursprünglichen Exit-Code) _b_on_exit() { local rc=$? trap - EXIT set +e if declare -F cleanup &>/dev/null; then cleanup "$rc" || bwarn "cleanup() meldete Fehler" fi [[ -n "$B_TMP_DIR" && -d "$B_TMP_DIR" ]] && rm -rf -- "$B_TMP_DIR" bdebug "Ende ${SCRIPT_NAME} (rc=${rc})" exit "$rc" } ## @brief ERR-Trap: meldet fehlgeschlagenen Befehl mit Funktion und Zeile ## (bewusstes 'return N' hat die Funktion schon selbst gemeldet → nur debug) ## @param $1 Zeilennummer ## @param $2 Befehl ## @return 0 (Abbruch übernimmt set -e) _b_on_err() { local rc=$? lvl=error [[ "$2" == return* ]] && lvl=debug blog "$lvl" "rc=${rc} in ${FUNCNAME[1]:-main}() Zeile ${1}: ${2}" return 0 } #=============================================================================== # Einstieg #=============================================================================== ## @brief Initialisiert das Script: Trap, .env, Defaults, Optionen, Logging, Hilfe ## Vorrang: Default < .env < Umgebung (B_*, OPTION_*) < Kommandozeile ## @param $@ Argumente des Scripts (danach: set -- "${ARGS[@]}") ## @return 0 ok; beendet bei --help (0) oder Aufruffehler (2) b_init() { local v long var env_file local -A env_keep=() declare -gA _B_CLI_SET=() trap _b_on_exit EXIT trap '_b_on_err "$LINENO" "$BASH_COMMAND"' ERR trap 'exit 130' INT trap 'exit 143' TERM # 1. exportierte Steuer-Variablen aus der Umgebung merken (gewinnen gegen .env) for v in $(compgen -e); do [[ "$v" =~ ^(B_[A-Z0-9_]+|OPTION_[A-Z0-9_]+)$ ]] && env_keep[$v]="${!v}" done # 2. .env laden (Bash-Syntax; relativ zum Script, nicht zum $PWD) env_file="$(b_abspath "${B_ENV_FILE:-.env}")" if [[ -f "$env_file" ]]; then # shellcheck source=/dev/null source "$env_file" || bdie "Fehler beim Laden von $env_file" fi for v in "${!env_keep[@]}"; do printf -v "$v" '%s' "${env_keep[$v]}"; done # 3. Defaults für alles, was weder .env noch Umgebung gesetzt hat : "${B_LOG_LEVEL:=info}" "${B_LOG_CONSOLE:=1}" "${B_LOG_FILE:=}" \ "${B_LOG_FILE_LEVEL:=}" "${B_LOG_COLOR:=auto}" [[ -n "${_B_LEVELS[$B_LOG_LEVEL]:-}" ]] || { bwarn "B_LOG_LEVEL=$B_LOG_LEVEL ungültig → info"; B_LOG_LEVEL=info; } [[ -n "$B_LOG_FILE" ]] && B_LOG_FILE="$(b_abspath "$B_LOG_FILE" "$SCRIPT_DIR")" _b_opts_build || exit 2 for long in "${_B_OPT_ORDER[@]}"; do var="$(_b_optvar "$long")" [[ -v "$var" ]] || printf -v "$var" '%s' "${_B_OPT_DEFAULT[$long]}" done # 4. Kommandozeile _b_opts_parse "$@" || bdie "Aufruffehler" 2 # 5. Standard-Optionen anwenden (( OPTION_DEBUG )) && B_LOG_LEVEL=debug (( OPTION_QUIET )) && B_LOG_CONSOLE=0 if [[ -n "${_B_CLI_SET[logfile]:-}" ]]; then B_LOG_FILE="$(b_abspath "$OPTION_LOGFILE" "$B_CALL_DIR")" # CLI: relativ zum $PWD elif [[ -n "$OPTION_LOGFILE" ]]; then B_LOG_FILE="$(b_abspath "$OPTION_LOGFILE" "$SCRIPT_DIR")" fi if [[ -n "$B_LOG_FILE" ]]; then mkdir -p -- "${B_LOG_FILE%/*}" 2>/dev/null && touch -- "$B_LOG_FILE" 2>/dev/null \ || { B_LOG_FILE=""; bwarn "Logdatei nicht beschreibbar – nur Konsole"; } fi (( OPTION_HELP )) && { b_usage; exit 0; } bdebug "Start ${SCRIPT_NAME} (_functions ${B_FUNCTIONS_VERSION}) in ${SCRIPT_DIR}, Aufruf aus ${B_CALL_DIR}" bdebug ".env: $( [[ -f "$env_file" ]] && echo "$env_file" || echo "keine" ) · Log: ${B_LOG_LEVEL}/${B_LOG_FILE:-ohne Datei}" for long in "${_B_OPT_ORDER[@]}"; do var="$(_b_optvar "$long")"; bdebug " ${var}=${!var}" done return 0 } # --- projektspezifische Erweiterungen ----------------------------------------- if [[ -f "${B_LIB_DIR}/_functions.local.sh" ]]; then # shellcheck source=/dev/null source "${B_LIB_DIR}/_functions.local.sh" fi