01 - Scripts con bash

La terminal repite trabajo: teclear de nuevo la misma secuencia de comandos es lento y propenso a erratas. Un script guarda esa secuencia en un fichero y la ejecuta en orden con un solo comando: el mismo resultado cada vez, y el fichero documenta cómo se consigue.

Bash es la shell habitual de los sistemas Unix: el intérprete que lee y ejecuta comandos línea a línea. Un script de bash es eso mismo —una secuencia de comandos— guardada en un fichero.

La primera línea de un script es el shebang: indica con qué intérprete se ejecuta.

#!/usr/bin/env bash

Se guarda con extensión .sh y necesita permiso de ejecución:

$ chmod +x saludo.sh      # solo la primera vez
$ ./saludo.sh

Los comentarios (#) no se ejecutan: explican el qué y el porqué. Los scripts se leen tanto como se escriben.

Cualquier secuencia de comandos cabe en un script. Un ejemplo cotidiano: automatizar compilar y ejecutar un programa:

#!/usr/bin/env bash
echo "Compilando..."
javac -d bin src/Main.java
echo "Ejecutando..."
java -cp bin Main
  • javac -d bin compila a la carpeta bin.
  • java -cp bin Main ejecuta la clase con bin en el *classpath*.
  • echo escribe en la salida estándar: los mensajes cuentan lo que el script hace.

Un script suele tener valores que cambian: se guardan en variables y se asignan una vez, al principio.

FUENTE="datos"
  • La asignación no lleva espacios alrededor del =: no es una comparación, es una asignación.
  • Para usarla, $FUENTE, y siempre entre comillas dobles: así un valor con espacios no se rompe.
  • Por convención, en MAYÚSCULAS: distingue la configuración de las demás líneas.

La sustitución de comandos, $( ), mete en una variable la salida de un comando: la shell lo ejecuta cada vez que corre el script.

HOY=$(date +%F)
DESTINO="copias/copia-$HOY.tgz"
echo "La copia de hoy irá a $DESTINO"

La shell conecta comandos: la salida de uno puede alimentar al siguiente o terminar en un fichero.

Sintaxis Efecto
comando > fichero escribe la salida en el fichero; si existe, lo sobreescribe
comando » fichero la añade al final del fichero
comando 2> fichero escribe ahí la salida de error
comando < fichero lee la entrada del fichero
comando1 \| comando2 tubería: la salida de uno es la entrada del otro
$ ls -l > listado.txt
$ find . -name "*.bak" 2> errores.txt    # los avisos de find, en un fichero
$ history | grep docker
$ ps aux | grep java
  • Los errores van por otra vía, el error estándar: por eso el 2>. Un fallo silencioso en la salida normal pasaría desapercibido.
  • Cada herramienta hace una cosa y la hace bien —grep busca, wc cuenta, sort ordena—; la tubería las compone.

Cada comando termina con un código de salida: 0 si tuvo éxito, distinto de 0 si falló. En la terminal no se ve, pero es el contrato con el que comandos y scripts se comunican.

$ cat noexiste.txt
cat: noexiste.txt: No such file or directory
$ echo $?          # el código del último comando
1
  • $?: el código de salida del último comando.
  • exit n: termina el script con ese código.

¿Qué ocurre cuando un comando falla y el script no lo recoge? Por defecto la shell sigue con la línea siguiente: los errores pasan si nadie los recoge. El comando set da órdenes a la propia shell sobre cómo comportarse mientras ejecuta el script —no es un comando más del script, es lo que cambia el marco en el que todo se ejecuta—. Se escribe al principio, con las opciones que se necesiten.

Opción La shell pasa a…
-e detener el script en cuanto un comando falla: fallar pronto, nada de continuar sobre un estado a medias
-u tratar como error el uso de una variable sin definir: la errata en un nombre deja de ser una cadena vacía invisible
-o pipefail dar por fallida una tubería si falla *cualquiera* de sus comandos, no solo el último

La combinación habitual en la cabecera de un script:

#!/usr/bin/env bash
set -euo pipefail

Cada opción cubre un modo distinto de esconder un fallo:

  • Sin -e, un tar que falla deja la copia a medias y el script sigue como si nada.
  • Sin -u, escribir $FUENT por $FUENTE produce una cadena vacía: los datos vacíos se usan sin que nadie se entere.
  • Sin -o pipefail, el código de salida de una tubería es el del último comando: el fallo del primer eslabón se pierde si el último acaba bien.
$ cat noexiste.txt | wc -l
cat: noexiste.txt: No such file or directory
0
$ echo $?
0
$ set -o pipefail
$ cat noexiste.txt | wc -l
cat: noexiste.txt: No such file or directory
0
$ echo $?
1

if ejecuta un bloque si la condición se cumple. Los corchetes son un comando de test: necesitan espacios alrededor y las variables entre comillas.

if [ -f "$FUENTE" ]; then
    tar -czf copia.tgz "$FUENTE"
else
    echo "No existe $FUENTE"
    exit 1
fi
Test Comprueba
-f que existe y es fichero
-d que existe y es directorio
-z que la cadena está vacía
= que dos cadenas son iguales
-eq que dos números son iguales
-gt que el primero es mayor que el segundo
-ge que el primero es mayor o igual
-lt que el primero es menor
-ne que los dos números son distintos

for recorre una lista; con la sustitución de comandos, la lista puede ser la salida de find:

for f in $(find . -name "*.txt"); do
    echo "Texto: $f"
done

while repite mientras la condición tenga éxito; con read, recorre líneas de un fichero:

while read -r linea; do
    echo "$linea"
done < ficheros.txt

Un script gana de verdad ante la terminal cuando admite argumentos como cualquier comando: ./tareas.sh copia y ./tareas.sh limpia son el mismo script con órdenes distintas. Cada argumento llega en una variable de posición: $1 es el primero, $2 el segundo… y $0 es el nombre del propio script.

#!/usr/bin/env bash
echo "Script: $0"
echo "Primero: $1"
echo "Segundo: $2"
$ ./saluda.sh hola adios
Script: ./saluda.sh
Primero: hola
Segundo: adios

$# cuenta cuántos argumentos han llegado. Con el test -gt del apartado anterior permite decidir si los hay:

if [ $# -gt 0 ]; then
    echo "Tarea pedida: $1"
else
    echo "No hay tarea"
    exit 1
fi

“$@” son *todos* los argumentos de golpe. Su uso natural es reenviarlos en bloque a otro comando: con los argumentos entre comillas dobles, cada uno llega al comando tal y como lo escribió el usuario —un valor con espacios no se rompe en dos—.

cp "$@" copias/          # los ficheros que hayan llegado, uno a uno

shift descarta $1 y desplaza el resto: el $2 pasa a ser $1, el $3 el $2… Es la forma de consumir el primer argumento cuando la orden está aparte de los datos: ./repo.sh add fichero1 fichero2.

orden="$1"
shift
echo "Orden: $orden"
echo "Resto: $#"         # ya solo cuentan los argumentos que le siguen
shift avisa y falla si no hay argumentos que descartar, y con set -e ese aviso detendría el script. Cuando el primer argumento *puede* no estar, el remate habitual es doble: la salida de error va al /dev/null —el desagüe donde se tira la que no interesa— y true —un comando que siempre tiene éxito— deja el script tranquilo:
orden="${1:-}"
shift 2>/dev/null || true

La expansión ${var:-valor} da un valor por defecto: se queda con valor si la variable está vacía o sin definir. Con los argumentos es casi obligatoria: si el script lleva set -u y se invoca sin argumentos, usar $1 es usar una variable sin definir y el script se detendría nada más empezar.

orden="${1:-}"            # cadena vacía si no llega nada
fichero="${2:-correo.txt}"

Una función agrupa una tarea bajo un nombre: se define una vez y el script la llama cuando la necesita.

copia() {
    echo "▶ Copiando..."
    tar -czf "$DESTINO" "$FUENTE"
}
 
limpia() {
    echo "▶ Limpiando..."
    rm -f "$FUENTE"/*.tmp
}
  • Una tarea, una función: el cuerpo del script se lee como la lista de tareas que hace.
  • El nombre elegido dice la tarea —copia, limpia—, no el comando que la resuelve.

El script admite un subcomando que elige la tarea: es el patrón de cualquier herramienta de línea de comandos, git commit, docker compose up… Un comando, una tarea. El case compara el primer argumento con cada caso y ejecuta el bloque correspondiente:

case "${1:-}" in
    copia)
        copia
        ;;
    limpia)
        limpia
        ;;
    *)
        uso
        exit 1
        ;;
esac
  • ${1:-} y no $1: si el script lleva set -u y se invoca sin argumentos, comparar $1 es usar una variable sin definir y el script se detendría nada más empezar.
  • ;; cierra cada caso.
  • *) es el resto: sin subcomando o con uno erróneo, el script enseña su uso —uso— y termina con error.

La función uso escribe el mensaje de ayuda. En varias líneas, mejor sin repetir echo a cada línea: con un heredoc (aquí-documento), el texto que sigue a «MARCA llega al comando como entrada, hasta la línea donde reaparece la marca.

uso() {
    cat <<'FIN'
uso: ./tareas.sh <comando> [argumentos...]
 
  copia      copia de seguridad de FUENTE
  limpia     borra los ficheros temporales
FIN
}
  • El texto va desde «FIN hasta la línea de la marca, y el comando lo recibe por su entrada: el cat lo imprime tal cual.
  • La marca entre comillas, «'FIN', entrega el texto *sin interpretar*: sin ella, la shell haría la sustitución de variables dentro del texto de ayuda.

./tareas.sh "${1:-}"$1copialimpiausoexit 1copia*limpia

Todo junto, el esqueleto de un script con varias tareas que admite argumentos:

#!/usr/bin/env bash
set -euo pipefail
 
# Configuración
FUENTE="datos"
HOY=$(date +%F)
DESTINO="copias/copia-$HOY.tgz"
 
# Ayuda
uso() {
    cat <<'FIN'
uso: ./tareas.sh <comando> [ficheros...]
 
  copia      copia de seguridad; con ficheros, solo esos
  limpia     borra los ficheros temporales
FIN
}
 
# Tareas
copia() {
    echo "▶ Copiando..."
    tar -czf "$DESTINO" "${@:-$FUENTE}"
}
 
limpia() {
    echo "▶ Limpiando..."
    rm -f "$FUENTE"/*.tmp
}
 
# Despacho
comando="${1:-}"
shift 2>/dev/null || true
 
case "$comando" in
    copia)
        copia "$@"
        ;;
    limpia)
        limpia
        ;;
    *)
        uso
        exit 1
        ;;
esac
  • set -euo pipefail en la cabecera: ni erratas ni tuberías a medias pasan desapercibidas.
  • ${1:-} + shift: se saca el comando y “$@” se queda solo con el resto de los argumentos.
  • ${@:-$FUENTE}: el truco del valor por defecto, sobre la lista — sin ficheros, la copia es de todo $FUENTE; con ellos, de los pedidos.
  • Las tareas que aprovechan los argumentos los reciben; las que no, ni se molestan.

El esqueleto es siempre el mismo: cabecera robusta, configuración, ayuda, tareas, despacho. Cambiar de tarea solo toca las funciones y el case; la forma del script no cambia.

  • clase/daw/si/bash.txt
  • Última modificación: 2026/10/31 19:28
  • por cesguiro