Llegamos al hito del módulo, y a una promesa que arrastramos desde 04-02. informe-diario.sh es hoy un programa robusto: valida su entorno, se protege con flock, aborta con diagnóstico, valida texto con regex y dirige su salida con precisión. Pero es también un único fichero de varios cientos de líneas donde conviven log_info, morir, validar_entorno y porcentaje —funciones que no tienen nada que ver con informes y que cualquier otro script del toolkit necesitaría—. La tentación, cuando escribas archivar-historico.sh la semana que viene, será copiar y pegar. Y ahí empieza el deterioro: dos copias que divergen, un fallo corregido en una y no en la otra, tres convenios de registro distintos. En esta lección conviertes ese fichero en un proyecto con arquitectura.

Contenido

  1. source frente a ejecutar
  2. Qué es una librería en Bash
  3. Localizar la librería sin que las rutas te traicionen
  4. Guardas de inclusión
  5. Nombres: qué es público y qué es privado
  6. Ficheros de configuración
  7. Orden de precedencia
  8. La estructura final del toolkit
  9. lib/comun.sh
  10. informe-diario.sh como orquestador
  11. Probar la librería a mano

  1. source frente a ejecutar

En 03-01 viste las formas de ejecutar un script. La que ahora importa es la que no crea un proceso: frente a ./script.sh o bash script.sh, que lanzan un proceso nuevo del que nada vuelve, source script.sh (o su forma POSIX . script.sh) ejecuta el fichero en este shell.

Ejecutar source
Proceso Uno nuevo (fork + exec) El actual
Variables y funciones que define Mueren con el hijo Quedan disponibles
cd que haga No te afecta Te cambia de directorio
Necesita permiso +x y shebang No
exit dentro Termina el script Termina tu shell

Las dos últimas filas explican por qué una librería no es un script normal. Como se carga con source, no necesita ni shebang ejecutable ni permiso de ejecución. Y sobre todo: un exit en una librería mata al que la carga, incluida tu sesión interactiva si la estabas probando; dentro de una librería se usa return. Esto no es nuevo: es el mismo mecanismo por el que ~/.bashrc define funciones que tu shell conoce (01-02). Una librería es un .bashrc para tus scripts.

  1. Qué es una librería en Bash

Una librería es un fichero que contiene solo definiciones: funciones, constantes y, como mucho, valores por defecto. Nada que actúe por sí mismo.

# lib/comun.sh — CORRECTO: solo define
log_info() { printf '%s [INFO] %s\n' "$(date '+%F %T')" "$*" >&2; }

# lib/malo.sh — INCORRECTO: todo esto ACTÚA al cargarse
log_info "cargando librería"          # ensucia la salida de quien la use
set -euo pipefail                     # IMPONE su política a quien la carga
cd /srv/veloz                         # cambia el directorio del que la carga

La regla es que cargar la librería debe ser silencioso y sin efectos. El set -euo pipefail dentro de una librería es especialmente traicionero: al hacer source se aplica al shell que la carga, así que puede cambiar el comportamiento de errores de un script que no lo esperaba. La política estricta la decide el ejecutable, no la librería. Convenciones: extensión .sh (o .bash), sin shebang ejecutable —opcionalmente un #!/usr/bin/env bash como pista para editores y para shellcheck, pero sin permiso +x—, y una cabecera de comentario que diga qué ofrece.

  1. Localizar la librería sin que las rutas te traicionen

Aquí está el problema técnico de verdad. ¿Cómo encuentra bin/informe-diario.sh a lib/comun.sh?

source lib/comun.sh               # MAL: relativo al directorio ACTUAL, no al script
source ~/veloz-ops/lib/comun.sh   # Funciona, pero solo para ti y en tu $HOME

Las rutas relativas se resuelven desde el directorio de trabajo actual, que es donde esté quien lanza el script, no donde vive el script. Como ~/veloz-ops/bin está en el PATH (lo pusimos ahí en 01-02), lo normal es invocarlo desde cualquier sitio, y el source falla. El idioma canónico es este:

BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; readonly BASE_DIR
source "$BASE_DIR/lib/comun.sh"

Desmontado de dentro afuera:

  • ${BASH_SOURCE[0]} es la ruta del fichero en ejecución. Se usa en lugar de $0 por dos motivos: $0 vale bash cuando el script se invoca como bash script.sh, y dentro de un fichero cargado con source, $0 sigue siendo el del script principal mientras que BASH_SOURCE[0] es la librería. Para una librería que necesita saber dónde está, $0 siempre da la respuesta equivocada.
  • dirname da el directorio que la contiene (04-04) y /.. sube a la raíz del proyecto; cd ... && pwd convierte esa ruta, que puede ser relativa (./bin/..), en absoluta y normalizada; y todo va entre comillas, por si la ruta contiene un espacio.

Con eso, el script funciona invocado como ./bin/informe-diario.sh, como ~/veloz-ops/bin/informe-diario.sh, desde el PATH o desde cron con cualquier directorio de trabajo. Si además quieres seguir enlaces simbólicos hasta el fichero real, readlink -f (05-01) sobre ${BASH_SOURCE[0]} antes del dirname lo resuelve.

  1. Guardas de inclusión

Si informe-diario.sh carga comun.sh y también informe.sh, que a su vez carga comun.sh, el fichero se procesa dos veces. Redefinir funciones es inofensivo, pero un readonly duplicado provoca un error fatal bajo modo estricto (VELOZ_VERSION: variable de solo lectura). La solución es la misma idea que los include guards de C:

[[ -n "${_COMUN_SH:-}" ]] && return 0        # ya cargada: salir sin hacer nada
readonly _COMUN_SH=1

El return 0 a nivel superior de un fichero cargado con source es legal y significa "termina de leer este fichero". El :- es obligatorio porque el script que la carga tiene set -u (05-03) y la variable aún no existe la primera vez. El guion bajo inicial marca la variable como interna.

  1. Nombres: qué es público y qué es privado

Bash no tiene espacios de nombres: todo vive en un único ámbito global. Si tu librería define log() y el script del compañero también, la última cargada gana silenciosamente. El remedio es un prefijo:

veloz::log_info()  { ... }      # estilo con dos puntos dobles
veloz_log_info()   { ... }      # estilo con guion bajo, más portable
_veloz_normaliza() { ... }      # guion bajo inicial: uso INTERNO, no lo llames

Bash permite :: en nombres de función y queda muy legible, pero no es POSIX y algunas herramientas antiguas se atragantan; veloz_ es la opción segura. Elige uno y sé consistente. La distinción público/privado es por convenio, no la impone el intérprete: el guion bajo inicial es una promesa de "esto puede cambiar sin aviso, no dependas de ello". Lo que sí puedes controlar de verdad es el ámbito de las variables: toda variable dentro de una función va con local (04-02), y así no contamina al script que la usa.

  1. Ficheros de configuración

~/veloz-ops/etc/veloz-ops.conf existe desde el principio del curso con permisos 600. Su forma más simple es un fichero de asignaciones (VELOZ_CSV=/srv/veloz/datos/envios.csv, VELOZ_LOG_NIVEL=info, VELOZ_CIUDADES="Valencia Sevilla Bilbao Madrid") que se carga con [[ -r "$BASE_DIR/etc/veloz-ops.conf" ]] && source "$BASE_DIR/etc/veloz-ops.conf". Es cómodo —admite comentarios, comillas, incluso $(...)— y tiene un riesgo que debes conocer: source ejecuta código arbitrario. Una línea rm -rf ~ en ese fichero se ejecuta con tus permisos. Por eso los 600 no son decorativos: si alguien puede escribir en tu fichero de configuración, puede ejecutar lo que quiera como tú. Las implicaciones completas se tratan en 08-03.

La alternativa segura es parsear clave=valor sin ejecutar nada, usando lo aprendido en 05-04:

# _veloz_cargar_conf — Carga clave=valor sin ejecutar código. Uso: ... <fichero>
_veloz_cargar_conf() {
    local fichero="${1:?}" clave valor linea
    [[ -r "$fichero" ]] || return 0
    while IFS= read -r linea; do
        [[ "$linea" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue
        clave="${BASH_REMATCH[1]}"; [[ "$clave" == VELOZ_* ]] || continue  # solo lo nuestro
        valor="${BASH_REMATCH[2]}"; valor="${valor%\"}"; valor="${valor#\"}"
        printf -v "$clave" '%s' "$valor"                          # asignación indirecta
    done < "$fichero"
}

Aquí se juntan tres módulos: la regex con BASH_REMATCH de 05-04 (que además descarta comentarios y líneas vacías al no encajar), las expansiones ${v%\"} de 04-04 y el bucle while read de 04-01. El filtro VELOZ_* impide que el fichero redefina PATH o HOME, y printf -v asigna a la variable cuyo nombre está en $clave: la forma segura de hacer asignación indirecta sin eval.

  1. Orden de precedencia

Cerramos aquí el diseño de opciones empezado en 03-05. Cuando el mismo valor puede venir de cuatro sitios, hace falta un orden explícito y documentado, de menor a mayor prioridad:

Nivel Origen Ejemplo Gana sobre
1 Valor por defecto en el código VELOZ_CSV=/srv/veloz/datos/envios.csv Nada
2 Fichero de configuración etc/veloz-ops.conf El defecto
3 Variable de entorno VELOZ_CSV=/tmp/x.csv informe-diario.sh Los anteriores
4 Opción de línea de comandos --csv /tmp/x.csv Todos

El principio: cuanto más cerca del usuario y del momento de la ejecución, más prioridad. Implementarlo es sencillo si respetas el orden de carga, y : "${VAR:=valor}" es el idioma para "asigna solo si está vacía o no definida" —el := asigna, a diferencia del :- que solo sustituye, y el : inicial es el comando nulo—, de modo que una variable de entorno ya definida sobrevive:

: "${VELOZ_CSV:=/srv/veloz/datos/envios.csv}"     # 1. defecto, solo si no existe
_veloz_cargar_conf "$BASE_DIR/etc/veloz-ops.conf" # 2. config (respeta lo ya definido)
# 3. el entorno ya estaba puesto antes de arrancar el script
while [[ $# -gt 0 ]]; do case "$1" in             # 4. opciones (03-05)
    --csv) VELOZ_CSV="$2"; shift 2 ;; *) break ;;
esac; done

  1. La estructura final del toolkit

~/veloz-ops/
├── bin/                    # ejecutables (+x, con shebang). Están en el PATH
│   ├── informe-diario.sh
│   └── archivar-historico.sh
├── lib/                    # librerías (sin +x, sin shebang, solo definiciones)
│   ├── comun.sh            # registro, errores, validaciones, utilidades
│   └── informe.sh          # lógica específica de informes
├── etc/veloz-ops.conf      # configuración (permisos 600)
└── logs/                   # salida: informes, trazas, ficheros de bloqueo

La regla de reparto en una frase: en bin/ va lo que se invoca, en lib/ lo que se reutiliza, en etc/ lo que cambia entre máquinas y en logs/ lo que se genera. Es la misma lógica del FHS que viste en 01-03, aplicada a escala de proyecto, y por eso resulta familiar a cualquiera que abra el repositorio. Un criterio práctico para decidir dónde va una función: si la necesitaría un script que no tuviera nada que ver con informes, va a comun.sh; si solo tiene sentido hablando de envíos y ciudades, va a informe.sh.

  1. lib/comun.sh

#!/usr/bin/env bash
# comun.sh — Utilidades compartidas del toolkit de Veloz Envíos.
# Cargar con: source "$BASE_DIR/lib/comun.sh"
[[ -n "${_COMUN_SH:-}" ]] && return 0
readonly _COMUN_SH=1 VELOZ_VERSION='1.0'
readonly _VELOZ_RE_FECHA='^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'

# veloz_log_info — Mensaje informativo a stderr. Uso: veloz_log_info <texto...>
veloz_log_info() {
    [[ "${VELOZ_LOG_NIVEL:-info}" == silencioso ]] && return 0
    printf '%s [INFO] %s\n' "$(date '+%F %T')" "$*" >&2
}
# veloz_log_error — Mensaje de error a stderr. Uso: veloz_log_error <texto...>
veloz_log_error() { printf '%s [ERROR] %s\n' "$(date '+%F %T')" "$*" >&2; }
# veloz_morir — Error fatal y salida. Uso: veloz_morir <código> <texto...>
veloz_morir() { local c="${1:?}"; shift; veloz_log_error "$*"; exit "$c"; }

# veloz_validar_fecha — ¿Es AAAA-MM-DD y existe? Uso: veloz_validar_fecha <fecha>
veloz_validar_fecha() { [[ "${1:-}" =~ $_VELOZ_RE_FECHA ]] && date -d "$1" &>/dev/null; }

# veloz_porcentaje — Calcula a/b*100 con 2 decimales. Uso: veloz_porcentaje <a> <b>
veloz_porcentaje() {
    (( ${2:?} == 0 )) && { printf '0.00\n'; return 0; }
    bc -l <<< "scale=2; ${1:?} * 100 / $2"
}
# veloz_validar_entorno — Comprueba dependencias y rutas. Uso: veloz_validar_entorno
veloz_validar_entorno() {
    local cmd
    for cmd in bc date find flock; do
        command -v "$cmd" > /dev/null || veloz_morir 69 "Falta la utilidad: $cmd"
    done
    [[ -r "${VELOZ_CSV:?sin definir}" ]] || veloz_morir 66 "No puedo leer $VELOZ_CSV"
    mkdir -p "${VELOZ_LOG_DIR:?}" || veloz_morir 73 "No puedo crear $VELOZ_LOG_DIR"
}

Fíjate en lo que no hay: ni set -euo pipefail, ni shebang ejecutable, ni una sola línea que actúe al cargarse más allá de la guarda y las constantes. veloz_morir es la única que llama a exit, y es correcto porque está pensada para usarse desde un ejecutable, no desde una sesión interactiva.

  1. informe-diario.sh como orquestador

#!/usr/bin/env bash
# informe-diario.sh — Informe diario de reparto de Veloz Envíos.
set -Eeuo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; readonly BASE_DIR
source "$BASE_DIR/lib/comun.sh"
source "$BASE_DIR/lib/informe.sh"
: "${VELOZ_CSV:=/srv/veloz/datos/envios.csv}"
: "${VELOZ_LOG_DIR:=$BASE_DIR/logs}"
_veloz_cargar_conf "$BASE_DIR/etc/veloz-ops.conf"

main() {
    local fecha subcomando; fecha=$(date +%F)
    while [[ $# -gt 0 ]]; do case "$1" in       # el bucle de opciones de 03-05
        --fecha) veloz_validar_fecha "${2:-}" || veloz_morir 64 "Fecha inválida: ${2:-}"
                 fecha="$2"; shift 2 ;;
        --debug) activar_debug; shift ;;
        -h|--help) uso; exit 0 ;;
        *) break ;;
    esac; done
    subcomando="${1:-resumen}"

    exec 9> "$VELOZ_LOG_DIR/.informe.lock"
    flock -n 9 || veloz_morir 75 "Ya hay un informe en curso"
    TRABAJO=$(mktemp -d); readonly TRABAJO
    trap 'rm -rf "$TRABAJO"' EXIT INT TERM
    veloz_validar_entorno
    acumular_dia "$fecha"
    case "$subcomando" in
        resumen)  generar_informe "$fecha" "$VELOZ_LOG_DIR/informe-$fecha.txt" ;;
        detalle)  tabla_detalle "$fecha" ;;
        ciudades) tabla_ciudades ;;
        *) veloz_morir 64 "Subcomando desconocido: $subcomando" ;;
    esac
}
main "$@"

Este es el resultado de cinco módulos. El ejecutable ya no implementa casi nada: localiza su base, carga sus librerías, resuelve la configuración, procesa opciones, se protege con flock y un trap, y despacha. Toda la lógica reutilizable vive en lib/. Cuando escribas archivar-historico.sh, las tres primeras líneas serán idénticas y tendrás gratis el registro, los errores y las validaciones.

  1. Probar la librería a mano

Una librería bien hecha se puede cargar en una sesión interactiva, y eso es lo que la hace verificable:

$ source ~/veloz-ops/lib/comun.sh
$ veloz_porcentaje 11 128                                  # 8.59
$ veloz_validar_fecha 2026-02-31 && echo ok || echo malo   # malo
$ declare -F | grep veloz          # todas las funciones definidas
$ type veloz_porcentaje            # ver el código de una función

declare -F lista los nombres de las funciones definidas y type muestra el cuerpo (01-04). Si al hacer source aparece cualquier salida, hay código de nivel superior que sobra. Para pruebas automáticas de verdad —afirmaciones, casos límite, informe de resultados— la herramienta es bats, en 08-06. Y shellcheck -x sigue los source para analizar también las librerías, en 08-05.

Errores Comunes y Consejos

  • source lib/comun.sh con ruta relativa. Funciona en tu directorio y falla desde cron o el PATH.
  • Usar $0 para localizar el script. Da bash con bash script.sh y el fichero equivocado dentro de una librería. Usa ${BASH_SOURCE[0]}.
  • exit dentro de una librería cargada en tu shell. Cierra tu terminal. Usa return.
  • Código de nivel superior en la librería, y en especial un set -euo pipefail: ensucia la salida e impone su política al script que la carga, quizá sin que lo espere.
  • readonly sin guarda de inclusión, que al cargarse dos veces da error fatal, y funciones sin local, que machacan variables del script que las llama semanas después.
  • Consejo: documenta cada función con una línea # nombre — qué hace. Uso: nombre <args> justo encima. Es lo que leerás dentro de seis meses, y lo que permite generar la ayuda del toolkit con un simple grep '^# [a-z]' lib/*.sh.

Ejercicios

Ejercicio 1. Escribe la cabecera completa de archivar-historico.sh (en bin/) que localice BASE_DIR de forma robusta, cargue lib/comun.sh, aplique modo estricto y falle con un mensaje claro si la librería no está donde debería.

Ejercicio 2. Añade a comun.sh una función veloz_config que devuelva el valor de una clave de configuración respetando el orden de precedencia (defecto < fichero < entorno), con guarda de inclusión y sin usar eval.

Ejercicio 3. Divide el toolkit: decide para cada una de estas funciones si va en comun.sh, en informe.sh o en el propio ejecutable, y justifica: veloz_log_info, tabla_ciudades, veloz_porcentaje, acumular_dia, uso, veloz_validar_fecha.

Soluciones

Solución 1.

#!/usr/bin/env bash
# archivar-historico.sh — Archiva el histórico mensual de Veloz Envíos.
set -Eeuo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 78
readonly BASE_DIR LIB="$BASE_DIR/lib/comun.sh"
[[ -r "$LIB" ]] || { printf 'Falta la librería %s\n' "$LIB" >&2; exit 78; }
source "$LIB"

La comprobación previa al source importa: sin ella, el mensaje sería source: lib/comun.sh: No such file or directory, que no dice dónde se buscó. El código 78 (EX_CONFIG, de la tabla de 05-03) es el adecuado: la instalación está mal, no los datos. Y el mensaje usa printf directo porque veloz_log_error aún no existe.

Solución 2.

# veloz_config — Devuelve el valor de una clave. Uso: veloz_config <CLAVE> [defecto]
veloz_config() {
    local clave="${1:?}" defecto="${2:-}" valor l
    valor="${!clave:-}"                       # 1) ¿está en el entorno o ya cargada?
    if [[ -z "$valor" && -r "${VELOZ_CONF:-}" ]]; then
        while IFS= read -r l; do              # 2) buscar en el fichero
            [[ "$l" =~ ^[[:space:]]*"$clave"=\"?([^\"]*)\"?$ ]] || continue
            valor="${BASH_REMATCH[1]}"; break
        done < "$VELOZ_CONF"
    fi
    printf '%s\n' "${valor:-$defecto}"        # 3) defecto como último recurso
}

${!clave} es la expansión indirecta: obtiene el valor de la variable cuyo nombre está en $clave. Es la lectura equivalente al printf -v de la sección 6, y ambas evitan eval, que ejecutaría el contenido del fichero de configuración —justo el riesgo del que huimos—. El orden de las tres ramas es la tabla de precedencia.

Solución 3.

Función Destino Motivo
veloz_log_info, veloz_porcentaje, veloz_validar_fecha lib/comun.sh Registro, aritmética y validación genéricos: no saben nada de envíos
acumular_dia, tabla_ciudades lib/informe.sh Conocen el formato del CSV, los estados de envío y su presentación
uso El ejecutable Cada script tiene sus propias opciones; no es reutilizable

El criterio es una sola pregunta: ¿lo necesitaría un script que no hable de envíos? Si la respuesta es sí, va a comun.sh. uso es el caso interesante: aunque todos los scripts tengan una, su contenido es distinto en cada uno, así que lo compartible no es la función sino el convenio de que exista.

Conclusión

source ejecuta un fichero en el shell actual, y de ahí sale todo lo demás: las funciones y variables que define quedan disponibles, no hace falta shebang ni permiso de ejecución, y un exit mataría a quien la carga —dentro de una librería se usa return—. Una librería contiene solo definiciones, sin código que actúe, sin set -euo pipefail que imponga políticas ajenas y sin mensajes al cargarse. Se localiza con BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)", nunca con $0 ni con rutas relativas, porque un script se invoca desde cualquier directorio y desde el PATH. Una guarda [[ -n ${_COMUN_SH:-} ]] && return 0 evita el doble cargado y el error de los readonly duplicados, y un prefijo veloz_ sustituye a los espacios de nombres que Bash no tiene, con el guion bajo inicial marcando lo privado. La configuración vive en etc/veloz-ops.conf con permisos 600 —porque cargarlo con source ejecuta lo que contenga— o se parsea como clave=valor con BASH_REMATCH y printf -v, y resuelve sus conflictos con una precedencia explícita: defecto < fichero < entorno < línea de comandos. El resultado es ~/veloz-ops/ con bin/, lib/, etc/ y logs/, y un informe-diario.sh que ya no implementa: orquesta.

Con esto se cierra el Módulo 5, y con él la parte del curso dedicada a lo que Bash sabe hacer por sí mismo. El toolkit de Veloz Envíos es hoy robusto, seguro con sus ficheros y procesos, capaz de diagnosticar sus propios fallos, preciso al validar texto, dueño de su entrada y salida, y modular. Pero sigue siendo torpe en un terreno concreto: cada vez que hay que hacer cuentas por columnas sobre el CSV, encadena cut, sort, uniq y bucles que leen el fichero varias veces; cada vez que hay que reescribir texto, faltan herramientas; y cuando la veloz-api deje de ser un proceso que se comprueba con pgrep y pase a ser una API que hay que consultar, Bash solo no llega.

En el Módulo 6 entran las herramientas externas que multiplican lo que Bash sabe hacer: awk, que procesa columnas y agrega en una sola pasada lo que hoy te cuesta veinte líneas (06-01); sed, para transformar texto en flujo con las regex que ya dominas (06-02); las órdenes para interrogar al sistema —disco, memoria, usuarios, hardware— (06-03); las herramientas de red y el /dev/tcp que dejamos apuntado (06-04); y curl con jq para hablar con la veloz-api y manejar JSON de verdad, que es donde tu toolkit dejará de leer ficheros para empezar a integrarse con el resto del sistema (06-05).

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados