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
javac -d bincompila a la carpetabin.java -cp bin Mainejecuta la clase conbinen el *classpath*.echoescribe en la salida estándar: los mensajes cuentan lo que el script hace.
2. Variables
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"
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
- 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 —
grepbusca,wccuenta,sortordena—; la tubería las compone.
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
$?: 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, untarque falla deja la copia a medias y el script sigue como si nada. - Sin
-u, escribir$FUENTpor$FUENTEproduce 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
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 }
- 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.
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
${1:-}y no$1: si el script llevaset -uy se invoca sin argumentos, comparar$1es 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
«FINhasta la línea de la marca, y el comando lo recibe por su entrada: elcatlo 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.
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
set -euo pipefailen 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.