Lección 8 de 8: un proyecto completo en Bash. Para terminar, un script de los que se usan de verdad, de principio a fin: ordena una carpeta de descargas según la extensión de cada archivo. Tiene archivo de configuración, una biblioteca de funciones, opciones con getopts, modo de ensayo, registro, códigos de salida y un cerrojo para no ejecutarse dos veces a la vez; lo comprobamos con unas pruebas escritas en el propio Bash, que encuentran un fallo real, y con ShellCheck, y lo dejamos programado con cron.
Qué vamos a construir
Una carpeta de descargas que se ordena sola: ordenar.sh mira la extensión de cada archivo y lo mueve a la subcarpeta que le toca, imagenes/, documentos/, audio/… Es un problema pequeño, pero tiene todo lo que tiene un script de verdad: un archivo de configuración que alguien puede escribir mal, nombres de archivo con espacios y guiones, archivos que ya existen en el destino, un modo de ensayo para ver lo que haría sin tocar nada, un registro de lo que hizo, códigos de salida que otro programa puede leer, pruebas y, al final, ejecución automática con cron.
Esta es la carpeta de partida. Ojo con los nombres: hay espacios, una mayúscula en la extensión, un archivo que empieza por guion, otro sin extensión y una captura que ya existe en imagenes/:
terminal$ ls -A Descargas Descargas/imagenes Descargas: -raro.txt LEEME cancion.mp3 captura 2026-10-30.png copia.tar.gz datos.csv foto-playa.JPG imagenes informe final.pdf instalador.deb notas.txt presupuesto.xlsx Descargas/imagenes: captura 2026-10-30.png
La configuración: texto, no código
Las reglas van en un archivo aparte, para cambiarlas sin tocar el script:
# ordenar.conf: a qué carpeta va cada extensión. # Formato: carpeta = extensiones sin punto, separadas por espacios. Lo que va tras # es un comentario. imagenes = jpg jpeg png gif webp heic documentos = pdf docx odt txt md hojas = xlsx ods csv audio = mp3 ogg flac wav comprimidos = zip gz xz 7z
La forma rápida de leer un archivo así sería escribirlo como variables de Bash y cargarlo con source. Es una mala idea: source ejecuta el archivo, así que cualquiera que pueda escribir la configuración puede ejecutar órdenes con los permisos del script, y una simple errata se convierte en un error de sintaxis a mitad de camino. Por eso cargar_config lo lee línea a línea y comprueba cada una con una expresión regular: lo que no tiene la forma carpeta = extensiones se rechaza con el número de línea, y el script se detiene con el código 2.
La biblioteca
Toda la lógica que se puede probar por separado va en ordenar_lib.sh: registrar mensajes, leer la configuración, decidir la carpeta de cada archivo, encontrar un nombre libre y mover. No tiene shebang ni se ejecuta sola; el script la carga con source. Fíjate en tres detalles que vienen de lecciones anteriores: todas las variables van entre comillas, las funciones usan local, y mover pone -- delante de los nombres para que uno que empiece por guion no se tome por una opción.
# ordenar_lib.sh: funciones de ordenar.sh. Se carga con source; no se ejecuta sola.
# shellcheck shell=bash
declare -gA REGLAS=() # extensión en minúsculas -> carpeta
# registrar NIVEL MENSAJE...: error y aviso van siempre a stderr, detalle solo con -v, e info y movido solo al
# registro. Todo va al archivo de registro, con fecha, si REGISTRO tiene una ruta.
registrar() {
local nivel=$1
shift
if [[ $nivel == error || $nivel == aviso || ($nivel == detalle && ${VERBOSO:-0} -eq 1) ]]; then
printf 'ordenar: %s\n' "$*" >&2
fi
if [[ -n ${REGISTRO:-} ]]; then
mkdir -p -- "$(dirname -- "$REGISTRO")"
printf '%(%F %T)T %-7s %s\n' -1 "$nivel" "$*" >> "$REGISTRO"
fi
}
# cargar_config ARCHIVO: lee las líneas «carpeta = ext ext...». No usa source: la configuración es texto, no
# código, así que ni un error ni un contenido malicioso se llegan a ejecutar.
cargar_config() {
local archivo=$1 n=0 linea carpeta ext
local -a exts
if [[ ! -r $archivo ]]; then
registrar error "no puedo leer la configuración: $archivo"
return 2
fi
while IFS= read -r linea || [[ -n $linea ]]; do
n=$((n + 1))
linea=${linea%%#*} # fuera los comentarios
[[ $linea =~ ^[[:space:]]*$ ]] && continue # y las líneas vacías
if [[ ! $linea =~ ^[[:space:]]*([A-Za-z0-9_-]+)[[:space:]]*=([[:space:]A-Za-z0-9]+)$ ]]; then
registrar error "$archivo, línea $n: no entiendo «$linea»"
return 2
fi
carpeta=${BASH_REMATCH[1]}
read -ra exts <<< "${BASH_REMATCH[2]}"
for ext in "${exts[@]}"; do
REGLAS[${ext,,}]=$carpeta
done
done < "$archivo"
if (( ${#REGLAS[@]} == 0 )); then
registrar error "$archivo no tiene ninguna regla"
return 2
fi
}
# carpeta_para NOMBRE: escribe la carpeta que le toca por su extensión; devuelve 1 si no hay regla.
carpeta_para() {
local nombre=$1 ext
[[ $nombre == ?*.* ]] || return 1 # sin extensión, o un oculto como .bashrc
ext=${nombre##*.}
ext=${ext,,}
[[ -n ${REGLAS[$ext]:-} ]] || return 1
printf '%s\n' "${REGLAS[$ext]}"
}
# nombre_libre CARPETA NOMBRE: una ruta que aún no existe: CARPETA/NOMBRE o «base (1).ext», «base (2).ext»...
nombre_libre() {
local carpeta=$1 nombre=$2 base ext n=1 ruta
ruta=$carpeta/$nombre
if [[ $nombre == ?*.* ]]; then
base=${nombre%.*} ext=.${nombre##*.}
else
base=$nombre ext=
fi
while [[ -e $ruta ]]; do
ruta="$carpeta/$base ($n)$ext"
n=$((n + 1))
done
printf '%s\n' "$ruta"
}
# mover ORIGEN DESTINO: como mv, pero sin pisar nada y aunque el nombre empiece por guion.
mover() {
mv -n -- "$1" "$2"
}El script principal
ordenar.sh se ocupa de lo que es propio de un programa: set -euo pipefail, encontrar su propia carpeta (para cargar la biblioteca y la configuración aunque se ejecute desde otro sitio), leer las opciones con getopts, validar los argumentos, impedir que corran dos copias a la vez y recorrer la carpeta. Cada salida tiene su código: así, quien lo llame, sea una persona, otro script o cron, sabe qué pasó sin leer los mensajes.
#!/usr/bin/env bash
# ordenar.sh: reparte los archivos de una carpeta en subcarpetas según su extensión.
# Uso: ordenar.sh [-n] [-v] [-c config] [-r registro] CARPETA (ordenar.sh -h para la ayuda)
set -euo pipefail
DIR_SCRIPT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=ordenar_lib.sh
source "$DIR_SCRIPT/ordenar_lib.sh"
uso() {
cat <<EOF
Uso: ${0##*/} [opciones] CARPETA
Reparte los archivos de CARPETA en subcarpetas según su extensión.
-n ensayo: dice lo que haría, sin mover nada
-v explica cada paso
-c ARCHIVO configuración (por omisión, ordenar.conf junto al script)
-r ARCHIVO registro (por omisión, ~/.local/state/ordenar.log)
-h esta ayuda
Códigos de salida: 0 bien, 1 uso incorrecto, 2 configuración o carpeta no válidas,
3 algún archivo no se pudo mover, 4 ya hay otra copia en marcha.
EOF
}
ayuda_corta() {
printf 'Prueba «%s -h» para ver la ayuda.\n' "${0##*/}" >&2
}
ENSAYO=0
VERBOSO=0
CONFIG=$DIR_SCRIPT/ordenar.conf
REGISTRO=$HOME/.local/state/ordenar.log
while getopts ':nvc:r:h' opcion; do
case $opcion in
n) ENSAYO=1 ;;
v) VERBOSO=1 ;;
c) CONFIG=$OPTARG ;;
r) REGISTRO=$OPTARG ;;
h) uso; exit 0 ;;
:) registrar error "a la opción -$OPTARG le falta su valor"; ayuda_corta; exit 1 ;;
*) registrar error "opción desconocida: -$OPTARG"; ayuda_corta; exit 1 ;;
esac
done
shift $((OPTIND - 1))
if (( $# != 1 )); then
registrar error "hay que indicar una carpeta, y solo una"
ayuda_corta
exit 1
fi
CARPETA=${1%/}
if [[ ! -d $CARPETA ]]; then
registrar error "no existe la carpeta: $CARPETA"
exit 2
fi
# Un cerrojo: si cron lanza el script mientras aún corre el anterior, el segundo se retira.
exec 9> "${XDG_RUNTIME_DIR:-/tmp}/ordenar-$(id -u).lock"
if ! flock -n 9; then
registrar error "ya hay otra copia en marcha"
exit 4
fi
cargar_config "$CONFIG" || exit 2
registrar detalle "empiezo con $CARPETA (ensayo: $ENSAYO)"
movidos=0 sin_regla=0 fallos=0
for ruta in "$CARPETA"/*; do
[[ -f $ruta ]] || continue # las subcarpetas se quedan como están
nombre=${ruta##*/}
if ! subcarpeta=$(carpeta_para "$nombre"); then
registrar detalle "sin regla, se queda: $nombre"
sin_regla=$((sin_regla + 1))
continue
fi
destino=$(nombre_libre "$CARPETA/$subcarpeta" "$nombre")
if (( ENSAYO )); then
printf 'movería: %s -> %s\n' "$nombre" "${destino#"$CARPETA"/}"
movidos=$((movidos + 1))
continue
fi
mkdir -p -- "$CARPETA/$subcarpeta"
if mover "$ruta" "$destino"; then
printf 'movido: %s -> %s\n' "$nombre" "${destino#"$CARPETA"/}"
registrar movido "$nombre -> ${destino#"$CARPETA"/}"
movidos=$((movidos + 1))
else
registrar aviso "no pude mover $nombre"
fallos=$((fallos + 1))
fi
done
if (( ENSAYO )); then
resumen="ensayo: se moverían $movidos, sin regla $sin_regla"
else
resumen="movidos $movidos, sin regla $sin_regla, con error $fallos"
fi
printf '%s\n' "$resumen"
registrar info "$resumen"
(( fallos == 0 )) || exit 3La ayuda es lo primero que conviene probar:
./ordenar.sh -hUso: ordenar.sh [opciones] CARPETA Reparte los archivos de CARPETA en subcarpetas según su extensión. -n ensayo: dice lo que haría, sin mover nada -v explica cada paso -c ARCHIVO configuración (por omisión, ordenar.conf junto al script) -r ARCHIVO registro (por omisión, ~/.local/state/ordenar.log) -h esta ayuda Códigos de salida: 0 bien, 1 uso incorrecto, 2 configuración o carpeta no válidas, 3 algún archivo no se pudo mover, 4 ya hay otra copia en marcha.
Probarlo: primero el ensayo
Con -n, el script dice lo que haría sin mover nada. Es lo primero que hay que ejecutar con una configuración nueva o con una carpeta que no conoces:
terminal$ chmod +x ordenar.sh $ ./ordenar.sh -n Descargas movería: -raro.txt -> documentos/-raro.txt movería: cancion.mp3 -> audio/cancion.mp3 movería: captura 2026-10-30.png -> imagenes/captura 2026-10-30 (1).png movería: copia.tar.gz -> comprimidos/copia.tar.gz movería: datos.csv -> hojas/datos.csv movería: foto-playa.JPG -> imagenes/foto-playa.JPG movería: informe final.pdf -> documentos/informe final.pdf movería: notas.txt -> documentos/notas.txt movería: presupuesto.xlsx -> hojas/presupuesto.xlsx ensayo: se moverían 9, sin regla 2 $ ls -A Descargas -raro.txt LEEME cancion.mp3 captura 2026-10-30.png copia.tar.gz datos.csv foto-playa.JPG imagenes informe final.pdf instalador.deb notas.txt presupuesto.xlsx
La captura repetida iría a captura 2026-10-30 (1).png en lugar de pisar la que ya está, -raro.txt no confunde a nadie y los dos archivos sin regla, instalador.deb y LEEME, se quedan donde estaban. copia.tar.gz va a comprimidos/ porque solo cuenta lo que hay tras el último punto, gz. Ahora sí, de verdad y con -v para ver también los mensajes de detalle:
terminal$ chmod +x ordenar.sh $ ./ordenar.sh -v Descargas ordenar: empiezo con Descargas (ensayo: 0) movido: -raro.txt -> documentos/-raro.txt ordenar: sin regla, se queda: LEEME movido: cancion.mp3 -> audio/cancion.mp3 movido: captura 2026-10-30.png -> imagenes/captura 2026-10-30 (1).png movido: copia.tar.gz -> comprimidos/copia.tar.gz movido: datos.csv -> hojas/datos.csv movido: foto-playa.JPG -> imagenes/foto-playa.JPG movido: informe final.pdf -> documentos/informe final.pdf ordenar: sin regla, se queda: instalador.deb movido: notas.txt -> documentos/notas.txt movido: presupuesto.xlsx -> hojas/presupuesto.xlsx movidos 9, sin regla 2, con error 0 $ find Descargas -type f | sort Descargas/LEEME Descargas/audio/cancion.mp3 Descargas/comprimidos/copia.tar.gz Descargas/documentos/-raro.txt Descargas/documentos/informe final.pdf Descargas/documentos/notas.txt Descargas/hojas/datos.csv Descargas/hojas/presupuesto.xlsx Descargas/imagenes/captura 2026-10-30 (1).png Descargas/imagenes/captura 2026-10-30.png Descargas/imagenes/foto-playa.JPG Descargas/instalador.deb $ cut -d' ' -f3- ~/.local/state/ordenar.log detalle empiezo con Descargas (ensayo: 0) movido -raro.txt -> documentos/-raro.txt detalle sin regla, se queda: LEEME movido cancion.mp3 -> audio/cancion.mp3 movido captura 2026-10-30.png -> imagenes/captura 2026-10-30 (1).png movido copia.tar.gz -> comprimidos/copia.tar.gz movido datos.csv -> hojas/datos.csv movido foto-playa.JPG -> imagenes/foto-playa.JPG movido informe final.pdf -> documentos/informe final.pdf detalle sin regla, se queda: instalador.deb movido notas.txt -> documentos/notas.txt movido presupuesto.xlsx -> hojas/presupuesto.xlsx info movidos 9, sin regla 2, con error 0
Los mensajes de -v (las líneas que empiezan por «ordenar:») van a la salida de errores, y el informe, a la normal, así que ./ordenar.sh Descargas > informe.txt guardaría solo el informe. El registro lo guarda todo, también lo que no sale en pantalla. La última orden lo muestra sin la fecha y la hora de cada línea, que cambian en cada ejecución; en el archivo están, para saber cuándo pasó cada cosa.
Los errores también se prueban
terminal$ chmod +x ordenar.sh $ ./ordenar.sh; echo "código: $?" ordenar: hay que indicar una carpeta, y solo una Prueba «ordenar.sh -h» para ver la ayuda. código: 1 $ ./ordenar.sh Documentos; echo "código: $?" ordenar: no existe la carpeta: Documentos código: 2 $ ./ordenar.sh -c; echo "código: $?" ordenar: a la opción -c le falta su valor Prueba «ordenar.sh -h» para ver la ayuda. código: 1 $ printf 'imagenes: jpg png\n' > mala.conf $ ./ordenar.sh -c mala.conf Descargas; echo "código: $?" ordenar: mala.conf, línea 1: no entiendo «imagenes: jpg png» código: 2
Cada mensaje dice qué falló y dónde, y cada fallo tiene su código. Ante un error de uso, el script no vuelca la ayuda entera, que taparía el mensaje importante: dice cómo verla. Con la configuración mal escrita, el script se detuvo antes de mover nada, que es justo lo que se quiere.
Falta el código 4. El cerrojo con flock (lo vimos en la lección 7) impide que corran dos copias a la vez; para comprobarlo, otra orden se queda con el cerrojo un segundo mientras lanzamos el script:
terminal$ chmod +x ordenar.sh $ flock /tmp/ordenar-$(id -u).lock sleep 1 & $ sleep 0.3; ./ordenar.sh Descargas; echo "código: $?" ordenar: ya hay otra copia en marcha código: 4 $ wait
Las pruebas
No hace falta instalar nada para tener pruebas automáticas: unas pocas líneas de Bash bastan. pruebas.sh carga la biblioteca, comprueba cada función con casos difíciles (mayúsculas, archivos ocultos, espacios, guiones, configuración mal escrita) y después ejecuta el script entero en una carpeta temporal, incluido el ensayo y los códigos de salida. Cada comprobación es una llamada a igual, que compara lo esperado con lo obtenido y lleva la cuenta:
#!/usr/bin/env bash
# pruebas.sh: pruebas de ordenar.sh y de su biblioteca, sin nada que instalar.
# Sin -e a propósito: una prueba que falla no debe cortar las demás.
set -uo pipefail
DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=ordenar_lib.sh
source "${LIB:-$DIR/ordenar_lib.sh}"
REGISTRO= # durante las pruebas, las funciones no escriben registro
pasadas=0 fallidas=0
igual() { # igual DESCRIPCIÓN ESPERADO OBTENIDO
if [[ $2 == "$3" ]]; then
pasadas=$((pasadas + 1))
printf 'ok %s\n' "$1"
else
fallidas=$((fallidas + 1))
printf 'FALLA %s\n esperaba: %q\n obtuve: %q\n' "$1" "$2" "$3"
fi
}
TMP=$(mktemp -d)
trap 'rm -rf -- "$TMP"' EXIT
printf 'imagenes = jpg png\ndocumentos = pdf txt\n' > "$TMP/prueba.conf"
# --- la biblioteca
cargar_config "$TMP/prueba.conf"
igual "jpg va a imagenes" imagenes "$(carpeta_para foto.jpg)"
igual "las mayúsculas dan igual" imagenes "$(carpeta_para FOTO.JPG)"
carpeta_para programa.deb > /dev/null
igual "sin regla devuelve 1" 1 "$?"
carpeta_para .bashrc > /dev/null
igual "un archivo oculto no tiene extensión" 1 "$?"
printf 'imagenes: jpg\n' > "$TMP/mala.conf"
cargar_config "$TMP/mala.conf" 2> /dev/null
igual "una configuración mal escrita devuelve 2" 2 "$?"
touch "$TMP/a b.txt"
igual "nombre libre si ya existe" "$TMP/a b (1).txt" "$(nombre_libre "$TMP" "a b.txt")"
mkdir "$TMP/doc"
touch "$TMP/informe final.pdf"
mover "$TMP/informe final.pdf" "$TMP/doc/informe final.pdf" 2> /dev/null
igual "mover un nombre con espacios" si "$([[ -f "$TMP/doc/informe final.pdf" ]] && echo si || echo no)"
cd -- "$TMP" || exit 1
touch -- -raro.txt
mover -raro.txt doc/-raro.txt 2> /dev/null
igual "mover un nombre que empieza por guion" si "$([[ -f doc/-raro.txt ]] && echo si || echo no)"
cd -- "$DIR" || exit 1
# --- el script entero
mkdir "$TMP/d"
touch "$TMP/d/foto.jpg" "$TMP/d/notas.txt" "$TMP/d/programa.deb"
"$DIR/ordenar.sh" -n -c "$TMP/prueba.conf" -r "$TMP/log" "$TMP/d" > /dev/null
igual "el ensayo no mueve nada" "foto.jpg notas.txt programa.deb" "$(cd "$TMP/d" && echo *)"
"$DIR/ordenar.sh" -c "$TMP/prueba.conf" -r "$TMP/log" "$TMP/d" > /dev/null
igual "ordena de verdad" "documentos/notas.txt imagenes/foto.jpg programa.deb" "$(cd "$TMP/d" && echo */* programa.deb)"
"$DIR/ordenar.sh" "$TMP/no-existe" 2> /dev/null
igual "carpeta inexistente: código 2" 2 "$?"
"$DIR/ordenar.sh" -x "$TMP/d" > /dev/null 2>&1
igual "opción desconocida: código 1" 1 "$?"
printf '\n%d pasadas, %d fallidas\n' "$pasadas" "$fallidas"
(( fallidas == 0 ))./pruebas.shok jpg va a imagenes ok las mayúsculas dan igual ok sin regla devuelve 1 ok un archivo oculto no tiene extensión ok una configuración mal escrita devuelve 2 ok nombre libre si ya existe ok mover un nombre con espacios ok mover un nombre que empieza por guion ok el ensayo no mueve nada ok ordena de verdad ok carpeta inexistente: código 2 ok opción desconocida: código 1 12 pasadas, 0 fallidas
Estas pruebas ya se ganaron el sueldo mientras escribíamos la lección. En la primera versión, la carpeta del registro se creaba después de validar los argumentos, así que con una carpeta inexistente el script intentaba escribir el error en ~/.local/state/ordenar.log antes de que existiera ~/.local/state, el printf fallaba, set -e cortaba el script y salía con el código 1 en lugar del 2. La prueba «carpeta inexistente: código 2» lo cazó a la primera; ahora es registrar quien crea la carpeta antes de escribir.
El script termina con (( fallidas == 0 )), así que su código de salida es 0 solo si todo pasó: cualquier sistema de integración continua lo entiende. Fíjate en que no usa set -e, a propósito: una prueba que falla no debe impedir que se ejecuten las demás.
El error que te ahorra: lo que encontraron las pruebas
La primera versión de mover era un mv sin comillas ni --. Con los nombres de prueba, «a.txt» y «b.txt», funcionaba. Estas son las mismas pruebas ejecutadas contra aquella versión (la variable LIB le indica qué biblioteca cargar):
LIB=./ordenar_lib_v1.sh ./pruebas.shok jpg va a imagenes
ok las mayúsculas dan igual
ok sin regla devuelve 1
ok un archivo oculto no tiene extensión
ok una configuración mal escrita devuelve 2
ok nombre libre si ya existe
FALLA mover un nombre con espacios
esperaba: si
obtuve: no
FALLA mover un nombre que empieza por guion
esperaba: si
obtuve: no
ok el ensayo no mueve nada
ok ordena de verdad
ok carpeta inexistente: código 2
ok opción desconocida: código 1
10 pasadas, 2 fallidasDos pruebas fallan: mv informe final.pdf … recibe tres argumentos en vez de dos, y mv -raro.txt … toma -raro.txt por opciones. ShellCheck también lo habría avisado antes de ejecutar nada:
#!/usr/bin/env bash
# La primera versión de mover(), la que encontraron las pruebas.
mover() {
mv $1 $2
}
mover "$@"shellcheckIn mover_v1.sh line 4:
mv $1 $2
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
mv "$1" "$2"
For more information:
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...ShellCheck: cero avisos
La última comprobación antes de dar el proyecto por bueno: ShellCheck sobre todos los archivos. La opción -x le deja seguir el source hasta la biblioteca, gracias al comentario # shellcheck source=ordenar_lib.sh que va justo encima:
terminal$ shellcheck -x ordenar.sh pruebas.sh ordenar_lib.sh && echo "ShellCheck: sin avisos" ShellCheck: sin avisos
Ninguna salida de ShellCheck es la mejor salida. Si un aviso no aplica en un caso concreto, se desactiva solo ahí con un comentario # shellcheck disable=SC2086 y una línea que explique por qué; nunca para todo el archivo.
Ojo con la seguridad: un script programado trabaja sin nadie mirando
Un script que corre con cron no tiene a nadie delante para ver un error, así que tiene que fallar de forma ruidosa y segura: detenerse ante una configuración rara antes de tocar nada, no pisar archivos, dejar registro y devolver un código distinto de cero. Y nunca debe cargar con source un archivo que pueda escribir otra persona: la configuración es texto y se valida como texto.
Más en Los fallos de seguridad típicos de los scripts de Bash.
Programarlo con cron
cron ejecuta órdenes según un calendario. Cada usuario tiene su tabla, que se edita con crontab -e; cada línea lleva cinco campos de tiempo (minuto, hora, día del mes, mes y día de la semana) y la orden. Para ordenar las descargas de lunes a viernes a las 8:30:
# m h dom mes dow orden 30 8 * * 1-5 /home/ana/bin/ordenar.sh /home/ana/Descargas >> /home/ana/.local/state/ordenar-cron.log 2>&1
Si no tienes claro qué significa una expresión, pégala en nuestro traductor de cron a español. Lo que más falla con cron no es el calendario, sino el entorno: cron no carga tu .bashrc, usa /bin/sh, un PATH mínimo y empieza en tu carpeta personal. Se puede imitar con env -i, que borra todas las variables, para ver lo que verá cron:
terminal$ chmod +x ordenar.sh $ cd ~ $ env -i HOME=/home/ana LOGNAME=ana SHELL=/bin/sh PATH=/usr/bin:/bin /bin/sh -c 'proyecto/ordenar.sh Descargas'; echo "código: $?" ordenar: no existe la carpeta: Descargas código: 2 $ env -i HOME=/home/ana LOGNAME=ana SHELL=/bin/sh PATH=/usr/bin:/bin /bin/sh -c '/home/ana/proyecto/ordenar.sh /home/ana/proyecto/Descargas'; echo "código: $?" movido: -raro.txt -> documentos/-raro.txt movido: cancion.mp3 -> audio/cancion.mp3 movido: captura 2026-10-30.png -> imagenes/captura 2026-10-30 (1).png movido: copia.tar.gz -> comprimidos/copia.tar.gz movido: datos.csv -> hojas/datos.csv movido: foto-playa.JPG -> imagenes/foto-playa.JPG movido: informe final.pdf -> documentos/informe final.pdf movido: notas.txt -> documentos/notas.txt movido: presupuesto.xlsx -> hojas/presupuesto.xlsx movidos 9, sin regla 2, con error 0 código: 0
La primera llamada falla: con cron, Descargas se busca en la carpeta personal, no donde estabas al probar. Por eso en la tabla de cron todo va con rutas absolutas, y la salida se redirige a un archivo (>> … 2>&1): si no, cron intenta enviarla por correo y, sin correo configurado, se pierde. Con eso, y con el cerrojo y los códigos de salida del script, ya puede trabajar solo.
Retos
Reto 1. ¿Qué hace ./ordenar.sh -n -v Descargas con LEEME y con instalador.deb?
Ver la solución
terminal$ chmod +x ordenar.sh $ ./ordenar.sh -n -v Descargas 2>&1 | grep "se queda" ordenar: sin regla, se queda: LEEME ordenar: sin regla, se queda: instalador.deb
Con -v, los archivos sin regla se anuncian en la salida de errores con «sin regla, se queda». LEEME no tiene extensión y .deb no está en la configuración. Para moverlos también, basta con añadir una línea como programas = deb.
Reto 2. ¿Por qué ordenar.sh no mueve los archivos ocultos, como .directory?
Ver la solución
Porque el patrón "$CARPETA"/* no incluye los nombres que empiezan por punto: es la regla de Bash para los globs, salvo que se active shopt -s dotglob. Aquí es lo que queremos: los archivos ocultos de una carpeta suelen ser de configuración de algún programa y no conviene moverlos. Además, carpeta_para los rechaza por su cuenta (es una de las pruebas).
Reto 3. Añade una opción -d CARPETA para dejar los archivos ordenados en otra carpeta de destino. ¿Qué tendrías que cambiar y qué prueba añadirías?
Ver la solución
Una variable DESTINO=$CARPETA antes de getopts, el caso d) DESTINO=$OPTARG ;; (con d: en la cadena de opciones), su línea en uso y, en el bucle, "$DESTINO/$subcarpeta" en lugar de "$CARPETA/$subcarpeta", comprobando antes que DESTINO existe (código 2 si no). La prueba: ordenar $TMP/d con -d $TMP/e y comprobar con igual que $TMP/e/imagenes/foto.jpg existe y que $TMP/d se quedó solo con lo que no tiene regla.
Todos los scripts de esta lección se ejecutaron con Bash 5.3 en Ubuntu 26.04, en un contenedor limpio sin acceso a la red, y se revisaron con ShellCheck 0.11 antes de publicarla; las salidas están copiadas de esa ejecución. ¿Algo se comporta distinto? Mira tu versión con bash --version: macOS trae de serie Bash 3.2, y en Windows hay que usar WSL o Git Bash.
