Connectly
Ingeniería2024-07-24

Mejora tus scripts usando 'direnv' y el script 'run'

Por Oliver Nguyen

Mejora tus scripts usando 'direnv' y el script 'run'

En el mundo de JavaScript/Node, usualmente almacenamos scripts en package.json y los ejecutamos usando npm. En otros mundos, usamos Makefile o creamos un directorio y ponemos todos nuestros scripts ahí. Pero hay una mejor manera de gestionar y ejecutar scripts para tu equipo. No, no estoy hablando de Warp ni de otras herramientas sofisticadas.

Compartiré sobre direnv y nuestro viejo amigo bash: cómo los usamos para escribir y gestionar scripts efectivamente como equipo.

direnv: establece automáticamente variables de entorno según el directorio actual

Para una introducción rápida, direnv es un cambiador de entorno para la shell. Puedes definir variables de entorno en un archivo .envrc dentro de un directorio, y estas variables se aplican automáticamente cuando haces cd a ese directorio. Cuando sales del directorio, las variables de entorno se descargan, asegurando que tu entorno se mantenga limpio y consistente.

Puedes instalarlo rápidamente desde tu gestor de paquetes favorito y agregar una línea a tu perfil de shell. Luego puedes crear un .envrc y poner cualquier export o comando en él. Sí, cualquier comando. Se ejecutarán automáticamente cuando entres al directorio. No, no te preocupes, no ejecutará código arbitrario de internet. Debes permitirlo explícitamente ingresando direnv allow -- cada vez que el contenido cambie.

Suficiente introducción. Ahora vamos a la parte divertida: ¿cómo lo usamos en la práctica?

Configura variables de entorno comunes para el proyecto

En la raíz de nuestro proyecto, tenemos un archivo .envrc que configura el entorno para nuestro proyecto. El primer caso de uso es declarar variables comunes para usar en scripts:

# .envrc
export  PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
export RELATIVE_PATH=$(git rev-parse --show-prefix   2>/dev/null)

Aunque el código se ve simple, cumple un propósito muy importante: al escribir scripts, sabemos que estas variables siempre están disponibles. La raíz del proyecto, y la ruta relativa al directorio actual. Ahora podemos usarlas donde queramos:

# run script (more on it later)
run-clean() {
    rm -r "$PROJECT_ROOT/js/node_modules"
    rm -r "$PROJECT_ROOT/.logs"
}
run-test() {
    bash -c "cd $PROJECT_ROOT/go  && go test ./..."
    bash -c "cd $PROJECT_ROOT/idl && go test ./..."
}
run-in-docker() {
    docker run -v "$PROJECT_ROOT:/root/src"  \
               -w "/root/src/$RELATIVE_PATH" \
                  "$DOCKER_IMAGE" "$@"
}

Haz que todos los scripts de un directorio estén disponibles automáticamente

Supongamos que nuestro proyecto tiene esta estructura:

backend/
├── .bin/
├── idl/
├── js/
├── go/
├── scripts/ 
│   ├── _cli.sh
│   └── build-all.sh
├── .envrc
└── run

Cuando estamos trabajando en un subdirectorio, podríamos querer ejecutar build-all.sh desde el directorio scripts. Sin direnv, podemos ejecutarlo especificando la ruta completa del script ~/Users/i/ws/backend/scripts/build-all.sh o mediante ../../../scripts/build-all.sh. Gracias a direnv, podemos hacer que todos los scripts en el directorio scripts estén siempre disponibles usando PATH_add:

# 'PATH_add' and 'path_add' are different commands
PATH_add "$PROJECT_ROOT/scripts"

¡Ahora podemos ejecutar build-all.sh desde cualquier directorio del proyecto! ¡Muy conveniente! Aún mejor, podemos poner todos nuestros scripts en un script run en la raíz del proyecto, y agregar esta línea al archivo .envrc:

PATH_add "$PWD"

La próxima vez, simplemente ejecuta build-all desde cualquier lugar. Más sobre eso después.

Diferentes configuraciones para diferentes directorios

Cuando estás dentro de un directorio, direnv buscará el archivo .envrc en ese directorio y lo ejecutará. Si el directorio no tiene un archivo .envrc, direnv subirá por el árbol hasta encontrar uno. Esto nos permite tener diferentes configuraciones para diferentes subproyectos. Como estas:

# go/.envrc
export DOCKER_IMAGE=golang:1.22
export CONTAINER_NAME=my-go-dev

Y en el directorio js:

# js/.envrc
export DOCKER_IMAGE=node:22-alpine
export CONTAINER_NAME=my-js-dev

Así que cuando ejecutamos docker desde cualquier directorio, usará la imagen de Docker y las configuraciones correctas.

Verifica que todos los desarrolladores usen las mismas herramientas y versiones

¿Recuerdas que podemos poner cualquier comando en el archivo .envrc? Podemos usar esto para asegurar que todos los desarrolladores usen las mismas herramientas y versiones. Por ejemplo, podemos verificar que la versión correcta de Go esté instalada:

# .envrc
if ! command -v go &> /dev/null; then
    echo "Go is not installed. Please install Go 1.22 👉 https://golang.org/dl"
    exit 1
fi
if ! go version | grep -q "go1.22"; then
    echo "Go 1.22 is required. Please install Go 1.22 👉 https://golang.org/dl"
    exit 1
fi

Podemos poner verificaciones similares para otras herramientas como Node, Docker, o cualquier otra herramienta que use tu proyecto. Esto asegura que todos los desarrolladores usen las mismas herramientas y versiones, lo que puede prevenir muchos problemas.

Consejo: pon las verificaciones anteriores en go/.envrc para que solo los desarrolladores que trabajan en la parte de Go del proyecto necesiten tener Go instalado. No queremos obligar a los desarrolladores de front-end a instalar Go, ¿verdad?

Incluye el archivo .envrc padre

Cuando tenemos un proyecto grande con múltiples directorios, y tenemos que copiar (y mantener) esos archivos .envrc en cada directorio, es momento de refactorizar. En lugar de copiar, podemos incluir el archivo .envrc padre. Esto es muy útil ya que podemos organizar nuestras configuraciones con herencia y sobrescrituras: una única fuente de verdad para configuraciones compartidas mientras permitimos que directorios específicos sobrescriban o extiendan estas configuraciones según sea necesario.

Usemos el comando source para incluir el archivo .envrc padre:

find-parent() {
    local dir=..
    while [[ -d "$dir" ]] && [[ ! -d "$dir/.git" ]]; do
        dir="${dir}/.." ;
    done
    echo "$dir"
}

parent-envrc=$(find-parent)/.envrc
if [[ ! -f "$parent-envrc" ]]; then
    echo "no parent .envrc found"
    exit 1
fi

# shellcheck source=/dev/null
source "$parent-envrc"

# we can override variables if needed
export DOCKER_IMAGE=golang:1.22

Hay muchos otros casos de uso para direnv si quieres explorar más. Ahora, pasemos al script run.

run: un simple script bash para gestionar todos los scripts de un proyecto

No es una herramienta real para instalar, sino un patrón para gestionar y ejecutar scripts. Creamos un archivo llamado run en el mismo directorio que .envrc y ponemos ahí nuestros scripts. Este archivo es una colección de funciones que podemos ejecutar desde la línea de comandos. Es como un Makefile, pero escrito en bash.

Un ejemplo

Un código vale más que mil palabras. Aquí hay un ejemplo del script run que usamos en nuestro proyecto:

#!/bin/bash
set -eo pipefail

run-calc() {
    result=$(echo "$@" | bc -l)
    if [[ "$JSON" == "1" ]] ; then
        echo '{"result": "'$result'"}'
    else
        echo "$result"
    fi
}
run-colors() {
    color(){
        for c; do
            printf '\e[48;5;%dm %03d ' $c $c
        done
        printf '\e[0m \n'
    }
    IFS=$' \t\n'
    color {0..15}
    for ((i=0;i<6;i++)); do
        color $(seq $((i*36+16)) $((i*36+51)))
    done
    color {232..255}
}
run-update-submodules() {
    git submodule update --init --recursive --remote
}
run-direnv-allow-all() {
    find . -name .envrc -exec bash -lc \
        'cd "$(dirname "{}")" && pwd && direnv allow' \;
}
run-generate-all() {
    bash -c "cd $PROJECT_ROOT/go  && go generate ./..."
    direnv exec "$PROJECT_ROOT/idl"  run generate
    RELATIVE_PATH="$PROJECT_ROOT/idl" run-in-docker buf generate
}
run-in-docker() {
    docker run -v "$PROJECT_ROOT:/root/src"  \
               -w "/root/src/$RELATIVE_PATH" \
               "$DOCKER_IMAGE" "$@"
}

# -------- this is the magic ------- #
source "$PROJECT_ROOT/scripts/_cli.sh"

Guarda ese script como run en el mismo directorio que .envrc. Por sí solo, el script run no hace nada. Es solo una colección de funciones. La magia ocurre en la última línea, cuando hace source del script _cli.sh.

_cli.sh: hace que todas las funciones del script run estén disponibles

Entonces, ¿qué hace _cli.sh? Su trabajo es detectar todas las funciones disponibles en el script run y permitirte ejecutarlas desde la terminal. Aquí está el contenido de _cli.sh:

#!/bin/bash
set -eo pipefail

show-help(){
    items=()
    while IFS='' read -r line; do items+=("$line"); done < \
        <(compgen -A "function" | grep "run-" | sed "s/run-//")
    printf -v items "\t%s\n" "${items[@]}"

    usage="USAGE: $(basename "$0") CMD [ARGUMENTS]
  CMD:\n$items"
    printf "$usage"
}

name=$1
case "$name" in
    "" | "-h" | "--help" | "help")
        show-help
        ;;
    *)
        shift
        if compgen -A "function" | grep "run-$name" >/dev/null ; then
            run-"${name}" "$@"
        else
            echo "ERROR: run-$name not found."
            exit 123
        fi
        ;;
esac

Probémoslo:

$ run
USAGE: run CMD [ARGUMENTS]
  CMD:
        calc
        colors
        direnv-allow-all
        generate-all
        in-docker
        update-submodules

¡Genial! Muestra todos los comandos disponibles, ordenados por nombre.
Ejecutemos un comando:

$ run colors
```![](/images/upgrade-your-scripts-using-direnv-and-run-script-2.png)

¡Hermoso! Muestra todos los colores en modo de 256 colores junto con sus códigos.  
¿Y qué pasa si un comando no existe?

$ run x ERROR: run-x not found.

$ echo $? 123


¡Ah! Muestra un error con código de salida.  
¿Podemos pasar argumentos y variables también?

$ run calc 2 ^ 16 65536

$ JSON=1 run calc '6 * 7' {"result": "42"}


¡Perfecto! Ahora estamos listos para agregar más scripts a run! 🚀🚀

#### Explicación: ¿cómo funciona?

- El script run declara las funciones que queremos ejecutar. Estas funciones empiezan con el prefijo run-.
- El script \_cli.sh detecta todas las funciones disponibles en el script run llamando a compgen -A "function".
- [compgen](https://tiswww.case.edu/php/chet/bash/bashref.html#index-completion-builtins) es un comando integrado de bash que genera posibles autocompletados para un comando.
- Canalizamos la salida de compgen a través de grep y sed para obtener todas las funciones que empiezan con el prefijo run-, y luego las guardamos en items. Esta es la lista de comandos disponibles. Luego podemos listarlas en run help.
- Para ejecutar un comando, verificamos si la función existe llamando a compgen de nuevo. Si existe, la llamamos con los argumentos proporcionados. Si no, mostramos un mensaje de error y salimos con código 123.

**Direnv y run son una combinación poderosa:**

- Sin direnv, necesitamos usar ./run colors para llamar al comando colors. Si estamos en un subdirectorio, se convierte en ../../../run colors.
- Gracias a direnv, podemos usar PATH\_add "$PWD" en .envrc y disfrutar llamando a run colors desde cualquier subdirectorio.
- La función run-in-docker es un buen ejemplo de cómo podemos usar direnv y run juntos. Usa las variables RELATIVE\_PATH y DOCKER\_IMAGE del archivo .envrc para ejecutar un comando en un contenedor Docker. *¿Recuerdas que podemos declarar esas variables en diferentes archivos**.envrc para diferentes directorios?*
- La función run-generate-all demuestra el uso de direnv exec y establecer manualmente RELATIVE\_PATH para ejecutar el comando en otros directorios.

### Usando direnv y run en la práctica

#### Predefine utilidades y hazlas siempre disponibles para los scripts

Por ejemplo, definamos algunas funciones para colorear texto en el script \_cli.sh:

_cli.sh

t is text

t-color() { printf "\e[38;5;%dm" "$1" ; } t-yellow() { t-color 3 ; } t-reset() { printf "\e[0m" ; }

p is printf

p-color() { local color=$1 ; shift printf "\e[38;5;%dm" "$color" printf "$@" printf "\e[0m" } p-red() { p-color 9 "$@" ; } p-green() { p-color 2 "$@" ; } p-blue() { p-color 4 "$@" ; } p-yellow() { p-color 3 "$@" ; } p-purple() { p-color 213 "$@" ; }

p-debug() { p-purple "DEBUG: $@\n" ; } p-info() { p-blue " INFO: $@\n" ; } p-warn() { p-yellow " WARN: $@\n" ; } p-error() { p-red "ERROR: $@\n" ; } p-success(){ p-green "✅ OK: $@\n" ; }


Luego podemos usarlas en nuestros scripts:

run

run-test() { p-info "Running tests..." if go test ./... ; then p-success "All tests passed." else p-error "Some tests failed." fi }


### Exige a los desarrolladores usar las mismas herramientas y versiones

Este es un ejemplo real de cómo podemos exigir a los desarrolladores usar las mismas versiones de Go. Ponemos esta verificación dentro de \_cli.sh, así que cada vez que un desarrollador ejecuta un comando, verificará si la versión correcta de Go está instalada:

_cli.sh

if [[ -e "$PROJECT_ROOT/go" ]] && [[ "$PROJECT_ROOT" != "/root/"* ]] ; then if ! which go >/dev/null 2>&1 ; then echo "" p-error " 🔥 go is not installed 🔥" p-info "-----------------------------------------------" p-info " 👉 HINT: download at https://golang.org/dl 👈 " p-info "-----------------------------------------------" exit 1 fi if ! go version | grep -q "go1.22" ; then echo "" p-error " 🔥 go version is not 1.22 🔥" p-info "-----------------------------------------------" p-info " 👉 HINT: download at https://golang.org/dl 👈 " p-info "-----------------------------------------------" exit 1 fi fi


- La verificación [[ -e "$PROJECT\_ROOT/go" ]] hace que Go sea un requisito solo para los desarrolladores que trabajan con Go.
- La verificación [[ "$PROJECT\_ROOT" != "/root/"\* ]] evita que la verificación se ejecute en contenedores Docker.

### Mantén los submódulos actualizados automáticamente

Muchas veces, los desarrolladores olvidaban actualizar los submódulos, lo que generaba problemas al ejecutar scripts que dependían de ellos. Podía costarles horas depurar o hacer preguntas y luego esperar respuestas horas después, solo para descubrir que olvidaron actualizar los submódulos. 😮‍💨

Podemos agregar una verificación a \_cli.sh para recordarles actualizar los submódulos cuando sea necesario:

_cli.sh

if git status | grep -Eq "\smodified:[^\n]+tooling (new commits)" ; then echo "" p-yellow " 🔥 Your tooling submodule is out of date. 🔥" p-yellow "-----------------------------------------------" if ask-yes-no "👉 Do you want to update submodules? 👈" ; then git submodule update --init --recursive --remote fi fi


- Si git status muestra modified: tooling (new commits), significa que el submódulo está desactualizado. El script preguntará al desarrollador antes de proceder a actualizar los submódulos.
- Solo verifica (new commits), así que cualquier otro cambio como contenido sin seguimiento o (new commits, modified content) en el submódulo no activará la advertencia.

La función ask-yes-no es una simple función para pedir confirmación al desarrollador:

_cli.sh

ask-yes-no() { echo "" while true; do t-yellow
read -p "$1 [y/n]: " yn case $yn in [Yy]* ) return 0;; # Yes [Nn]* ) return 1;; # No * ) p-warn "Please answer yes or no? 🔥";; esac done }


### Configura automáticamente el entorno local

De manera similar, podemos verificar si jq está instalado. Si no, podemos instalar gojq y crear un alias a jq en el directorio .bin.  
Nota que necesitamos usar PATH\_add "$PROJECT\_ROOT/.bin" en el archivo .envrc para hacer que el comando jq esté disponible.

_cli.sh

if ! which jq &>/dev/null ; then echo "" p-yellow " 🔥 You do not have jq installed. 🔥" p-yellow "-----------------------------------------------" if ask-yes-no "👉 Do you want to automatically install gojq? 👈" ; then p-info "installing gojq..." bash -c 'cd ~ ; go install github.com/itchyny/gojq/cmd/gojq@latest' if ! which gojq &>/dev/null ; then p-error "Failed to install gojq. Please install jq or gojq manually." exit 1 fi mkdir -p "$PROJECT_ROOT/.bin" echo '#!/bin/bash set -eo pipefail gojq "$@"' > "$PROJECT_ROOT/.bin/jq" chmod +x "$PROJECT_ROOT/.bin/jq" fi fi


### Carga secretos sensibles

Algunos comandos requieren secretos sensibles para ejecutarse. Podemos crear una función utilitaria para cargar esos secretos desde AWS Secrets Manager y usarlos más adelante en otros scripts:

run-login() { api_key=$($awscli secretsmanager get-secret-value --secret-id 'my-secret-id' | jq -r '.SecretString' | jq -r ".MY_API_KEY") if [[ -z "$api_key" ]] ; then p-error "MY_API_KEY not found" ; exit 1 ; fi

echo "$api_key" | run-in-docker ./scripts/login.sh

} run-build() { run-login exec-build "$@" }


### Reconstruye automáticamente los comandos cuando sea necesario usando hash

Supongamos que tenemos una herramienta cli personalizada .bin/mycli que usamos en nuestros scripts. Podemos calcular el hash a partir del código fuente de la herramienta y compararlo con el hash local de la herramienta instalada. Si son diferentes, el script reconstruirá automáticamente la herramienta y actualizará el hash local.

run-mycli() { build-mycli $PROJECT_ROOT/.bin/mycli "$@" } build-mycli() { hash=$(cat $PROJECT_ROOT/go/scripts/mycli/.hash 2>/dev/null) rebuild-if-needed mycli "$hash" build-mycli-always } build-mycli-always() { mkdir -p "$PROJECT_ROOT/.bin" bash -c "cd $PROJECT_ROOT/go &&
go build -o $PROJECT_ROOT/.bin/mycli ./scripts/mycli &&
go run ./scripts/mycli calc-hash &>/dev/null" } rebuild-if-needed() { local bin="$1" local hash="$2" local build_script="$3"

mkdir -p "$PROJECT_ROOT/.bin"
if [[ ! $(ls "$PROJECT_ROOT/.bin/$bin" 2>/dev/null) ]] ||\
   [[ "$hash" != "$(cat "$PROJECT_ROOT/.bin/$bin.hash" 2>/dev/null)" ]]; then
    printf "building... "
    $build_script
    printf "\r              \r"
    echo "$hash" > "$PROJECT_ROOT/.bin/$bin.hash"
fi

}


### Conclusión

Al integrar direnv y los scripts run en nuestro flujo de desarrollo, podemos crear un entorno poderoso, eficiente y consistente que beneficia enormemente al equipo. ¡Y tú también puedes! 💪💪

Son fáciles de configurar y usar, ahorrándote a ti y a tu equipo mucho tiempo y esfuerzo. Con direnv, puedes configurar variables de entorno para tu proyecto y hacerlas disponibles en todos tus scripts. Con run, puedes crear una colección de funciones que puedes ejecutar desde la línea de comandos, ajustadas para cada directorio. Juntos, pueden ayudarte a escribir y gestionar scripts de manera más efectiva como equipo! 🚀🚀

### Autor

*Soy Oliver Nguyen -- ingeniero de software en C*[*onnectly.ai*](https://connectly.ai)*. Disfruto aprender y ver una mejor versión de mí mismo cada día. Ocasionalmente lanzo nuevos proyectos de código abierto. Comparto conocimiento y reflexiones durante mi camino.*

*Esta publicación también está publicada en* [*olivernguyen.io*](https://olivernguyen.io/w/direnv.run/)*.*