Tabla de Contenidos

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.

1. De comandos a script

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

2. Variables

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

FUENTE="datos"

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"

3. Tuberías y redirecciones

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

4. Errores y códigos de salida

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

¿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:

$ 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

5. Condicionales y bucles

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

6. Los argumentos del script

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}"

7. Funciones

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
}

8. Subcomandos con case

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

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
}

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

9. Componerlo todo

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

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.