Connectly
Engenharia2024-07-24

Dê um upgrade nos seus scripts usando 'direnv' e o script 'run'

Por Oliver Nguyen

Dê um upgrade nos seus scripts usando 'direnv' e o script 'run'

No mundo JavaScript/Node, normalmente guardamos scripts no package.json e os executamos usando npm. Em outros mundos, usamos Makefile ou criamos um diretório e colocamos todos os nossos scripts lá. Mas existe uma forma melhor de gerenciar e executar scripts para o seu time. Não, não estou falando do Warp ou de outras ferramentas chiques.

Vou compartilhar sobre o direnv e nosso velho amigo, o bash: como estamos usando eles para escrever e gerenciar scripts de forma eficaz como equipe.

direnv: definindo variáveis de ambiente automaticamente com base no diretório atual

Para uma introdução rápida, o direnv é um trocador de ambiente para o shell. Você pode definir variáveis de ambiente em um arquivo .envrc dentro de um diretório, e essas variáveis são aplicadas automaticamente quando você entra (cd) nesse diretório. Quando você sai do diretório, as variáveis de ambiente são descarregadas, garantindo que seu ambiente permaneça limpo e consistente.

Você pode rapidamente instalá-lo pelo seu gerenciador de pacotes favorito e adicionar uma linha ao seu perfil de shell. Depois, você pode criar um .envrc e colocar qualquer export ou comando nele. Sim, qualquer comando. Eles serão executados automaticamente quando você entrar no diretório. Não, não se preocupe, isso não vai rodar código arbitrário vindo da internet. Você precisa permitir isso explicitamente, digitando direnv allow -- toda vez que o conteúdo mudar.

Isso já basta como introdução. Agora vamos à parte divertida: como usamos isso na prática?

Configurando variáveis de ambiente comuns para o projeto

Na raiz do nosso projeto, temos um arquivo .envrc que configura o ambiente do nosso projeto. O primeiro caso de uso é declarar variáveis comuns para uso nos scripts:

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

Embora o código pareça simples, ele cumpre um propósito muito importante: ao escrever scripts, sabemos que essas variáveis estão sempre disponíveis. A raiz do projeto e o caminho relativo até o diretório atual. Agora podemos usá-las onde quisermos:

# 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" "$@"
}

Deixando todos os scripts de um diretório disponíveis automaticamente

Vamos assumir que nosso projeto tem esta estrutura:

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

Quando estamos trabalhando em um subdiretório, podemos querer executar build-all.sh a partir do diretório scripts. Sem o direnv, podemos executá-lo especificando o caminho completo do script ~/Users/i/ws/backend/scripts/build-all.sh, ou por ../../../scripts/build-all.sh. Graças ao direnv, podemos deixar todos os scripts do diretório scripts sempre disponíveis usando o PATH_add:

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

Agora podemos executar build-all.sh a partir de qualquer diretório do projeto! Que conveniente! Melhor ainda, podemos colocar todos os nossos scripts em um script run na raiz do projeto e adicionar esta linha ao arquivo .envrc:

PATH_add "$PWD"

Da próxima vez, basta executar build-all de qualquer lugar. Mais sobre isso depois.

Configurações diferentes para diretórios diferentes

Quando estamos dentro de um diretório, o direnv vai procurar pelo arquivo .envrc naquele diretório e executá-lo. Se o diretório não tiver um arquivo .envrc, o direnv vai subir na árvore de diretórios até encontrar um. Isso nos permite ter configurações diferentes para subprojetos diferentes. Como estas:

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

E no diretório js:

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

Então, quando executamos o docker a partir de qualquer diretório, ele vai usar a imagem Docker e as configs corretas.

Verificando se todos os desenvolvedores usam as mesmas ferramentas e versões

Lembra que podemos colocar qualquer comando no arquivo .envrc? Podemos usar isso para garantir que todos os desenvolvedores usem as mesmas ferramentas e versões. Por exemplo, podemos verificar se a versão correta do 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 colocar verificações similares para outras ferramentas, como Node, Docker ou qualquer outra ferramenta que o seu projeto use. Isso garante que todos os desenvolvedores usem as mesmas ferramentas e versões, o que pode evitar muitos problemas.

Dica: coloque as verificações acima em go/.envrc, para que apenas os desenvolvedores que trabalham na parte de Go do projeto precisem ter o Go instalado. Não queremos forçar os desenvolvedores front-end a instalar Go, certo?

Incluindo o arquivo .envrc pai

Quando temos um projeto grande com múltiplos diretórios, e precisamos copiar (e manter) esses arquivos .envrc em cada diretório, é hora de refatorar! Em vez de copiar, podemos incluir o arquivo .envrc pai. Isso é muito útil, já que conseguimos organizar nossas configs com herança e sobrescritas: uma única fonte de verdade para configurações compartilhadas, enquanto diretórios específicos podem sobrescrever ou estender essas configurações conforme necessário.

Vamos usar o comando source para incluir o arquivo .envrc pai:

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

Existem muitos outros casos de uso para o direnv, se você quiser explorar mais. Agora, vamos passar para o script run.

run: um script bash simples para gerenciar todos os scripts de um projeto

Não é uma ferramenta real para você instalar, mas um padrão para gerenciar e executar scripts. Criamos um arquivo chamado run no mesmo diretório do .envrc e colocamos nossos scripts lá. Esse arquivo é uma coleção de funções que podemos executar pela linha de comando. É como um Makefile, mas escrito em bash.

Um exemplo

Um código vale mais que mil palavras. Aqui está um exemplo de script run que usamos no nosso projeto:

#!/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"

Salve esse script como run no mesmo diretório do .envrc. Por si só, o script run não faz nada. É apenas uma coleção de funções. A mágica acontece na última linha, quando ele faz source do script _cli.sh.

_cli.sh: deixando todas as funções do script run disponíveis

Então o que o _cli.sh faz? O trabalho dele é detectar todas as funções disponíveis no script run e permitir que você as execute pelo terminal. Aqui está o conteúdo do _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

Vamos testar:

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

Legal! Ele mostra todos os comandos disponíveis, ordenados por nome. Vamos executar um comando:

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

Lindo! Ele mostra todas as cores no modo 256 cores, junto com seus códigos.
E o que acontece se um comando não existir?

$ run x ERROR: run-x not found.

$ echo $? 123


Ah! Mostra um erro com código de saída.
Podemos passar argumentos e variáveis também?

$ run calc 2 ^ 16 65536

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


Perfeito! Agora estamos prontos para adicionar mais scripts ao run! 🚀🚀

#### Explicação: como funciona?

- O script run declara as funções que queremos executar. Essas funções começam com o prefixo run-.
- O script \_cli.sh detecta todas as funções disponíveis no script run chamando compgen -A "function".
- O [compgen](https://tiswww.case.edu/php/chet/bash/bashref.html#index-completion-builtins) é um comando built-in do bash que gera possíveis complementos para um comando.
- Passamos a saída do compgen por grep e sed para obter todas as funções que começam com o prefixo run-, e então as salvamos em items. Essa é a lista de comandos disponíveis. Depois, podemos listá-los em run help.
- Para executar um comando, verificamos se a função existe chamando o compgen novamente. Se existir, chamamos ela com os argumentos fornecidos. Se não, mostramos uma mensagem de erro e saímos com o código 123.

**Direnv e run são uma combinação poderosa:**

- Sem o direnv, precisamos usar ./run colors para chamar o comando colors. Se estivermos em um subdiretório, isso se torna ../../../run colors.
- Graças ao direnv, podemos usar PATH\_add "$PWD" no .envrc e aproveitar para chamar run colors de qualquer subdiretório.
- A função run-in-docker é um bom exemplo de como podemos usar direnv e run juntos. Ela usa as variáveis RELATIVE\_PATH e DOCKER\_IMAGE do arquivo .envrc para executar um comando em um container Docker. *Lembra que podemos declarar essas variáveis em arquivos**.envrc diferentes para diretórios diferentes?*
- A função run-generate-all demonstra o uso do direnv exec e a definição manual de RELATIVE\_PATH para executar o comando em outros diretórios.

### Usando direnv e run na prática

#### Pré-definindo utilitários e deixando-os sempre disponíveis para os scripts

Por exemplo, vamos definir algumas funções para colorir texto no 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" ; }


Depois podemos usá-las nos nossos scripts:

run

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


### Exigindo que os desenvolvedores usem as mesmas ferramentas e versões

Este é um exemplo real de como podemos obrigar os desenvolvedores a usar as mesmas versões do Go. Colocamos essa verificação dentro do \_cli.sh, então, toda vez que um desenvolvedor executa um comando, ele vai verificar se a versão correta do 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


- A verificação [[ -e "$PROJECT\_ROOT/go" ]] serve para tornar o Go um requisito apenas para os desenvolvedores que trabalham com Go.
- A verificação [[ "$PROJECT\_ROOT" != "/root/"\* ]] serve para impedir que a checagem rode dentro de containers Docker.

### Mantendo os submódulos atualizados automaticamente

Muitas vezes, os desenvolvedores esqueciam de atualizar os submódulos, o que causava problemas ao executar scripts que dependiam deles. Isso podia custar horas de debug, ou fazer com que perguntassem e esperassem horas por respostas, só para descobrir depois que tinham esquecido de atualizar os submódulos. 😮‍💨

Podemos adicionar uma verificação ao \_cli.sh para lembrá-los de atualizar os submódulos quando necessário:

_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


- Se o git status mostrar modified: tooling (new commits), significa que o submódulo está desatualizado. O script vai perguntar ao desenvolvedor antes de atualizar os submódulos.
- Ele só verifica (new commits), então quaisquer outras mudanças, como conteúdo não rastreado ou (new commits, modified content) no submódulo, não vão disparar o alerta.

O ask-yes-no é uma função simples para pedir a confirmação do desenvolvedor:

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


### Configurando o ambiente local automaticamente

Da mesma forma, podemos verificar se o jq está instalado. Se não estiver, podemos instalar o gojq e criar um alias para jq no diretório .bin.
Note que precisamos usar PATH\_add "$PROJECT\_ROOT/.bin" no arquivo .envrc para deixar o comando jq disponível.

_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


### Carregando segredos sensíveis

Alguns comandos exigem segredos sensíveis para funcionar. Podemos criar uma função utilitária para carregar esses segredos do AWS Secrets Manager e usá-los depois em outros 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 "$@" }


### Reconstruindo comandos automaticamente quando necessário usando hash

Digamos que temos uma ferramenta cli customizada, .bin/mycli, que usamos nos nossos scripts. Podemos calcular o hash a partir do código-fonte da ferramenta e compará-lo com o hash local da ferramenta instalada. Se forem diferentes, o script vai reconstruir automaticamente a ferramenta e atualizar o 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

}


### Conclusão

Ao integrar o direnv e os scripts run no nosso workflow de desenvolvimento, conseguimos criar um ambiente poderoso, eficiente e consistente que beneficia muito a equipe. E você também pode! 💪💪

Eles são fáceis de configurar e usar, economizando muito tempo e esforço para você e sua equipe. Com o direnv, você pode configurar variáveis de ambiente para o seu projeto e deixá-las disponíveis em todos os seus scripts. Com o run, você pode criar uma coleção de funções que você pode executar pela linha de comando, ajustadas para cada diretório. Juntos, eles podem ajudar você a escrever e gerenciar scripts de forma mais eficaz como equipe! 🚀🚀

### Autor

*Eu sou o Oliver Nguyen -- engenheiro de software na C*[*onnectly.ai*](https://connectly.ai)*. Gosto de aprender e de me tornar uma versão melhor de mim mesmo todos os dias. De vez em quando, crio novos projetos open source. Compartilho conhecimento e reflexões durante essa jornada.*

*O post também foi publicado em* [*olivernguyen.io*](https://olivernguyen.io/w/direnv.run/)*.*