Connectly
技术2024-07-24

用「direnv」和「run」脚本升级你的脚本管理方式

作者:Oliver Nguyen

用「direnv」和「run」脚本升级你的脚本管理方式

在 JavaScript/Node 的世界里,我们通常把脚本存放在 package.json 中,用 npm 来运行它们。在其他世界里,我们会用 Makefile,或者建一个目录把所有脚本放进去。但对于团队来说,管理和运行脚本还有更好的方式。不,我说的不是 Warp 或其他花哨的工具。

我要分享的是 direnv 以及我们的老朋友 bash:我们如何用它们作为一个团队高效地编写和管理脚本。

direnv:根据当前目录自动设置环境变量

简单介绍一下,direnv 是一个 shell 环境切换器。你可以在某个目录下的 .envrc 文件中定义环境变量,当你 cd 进入该目录时,这些变量会自动生效。当你离开该目录时,这些环境变量会被卸载,确保你的环境始终保持干净、一致。

你可以用你喜欢的包管理器快速安装它,并在你的 shell 配置文件中添加一行。之后你就可以创建一个 .envrc 文件,把任何 export 或命令都放进去。是的,任何命令都可以。当你进入这个目录时,它们都会自动执行。不用担心,它不会运行来自网络上的任意代码。你必须显式地允许它——每次内容发生变化时,都要输入 direnv allow。

介绍就到这里。现在,来讲讲有意思的部分:我们在实践中是怎么用它的?

为项目设置通用环境变量

在项目的根目录下,我们有一个 .envrc 文件来配置项目的环境。第一个用例是声明一些供脚本使用的通用变量:

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

这段代码看起来很简单,但它的作用非常重要:在编写脚本时,我们知道这些变量总是可用的——项目根目录,以及相对当前目录的路径。现在我们可以在任何地方使用它们:

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

让某个目录下的所有脚本自动可用

假设我们的项目有这样的目录结构:

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

当我们在某个子目录下工作时,可能想运行 scripts 目录下的 build-all.sh。如果没有 direnv,我们可以通过指定完整路径 ~/Users/i/ws/backend/scripts/build-all.sh 或者相对路径 ../../../scripts/build-all.sh 来运行它。得益于 direnv,我们可以使用 PATH_add,让 scripts 目录下的所有脚本始终可用:

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

现在,我们可以在项目的任意目录下运行 build-all.sh 了!非常方便!更进一步,我们可以把所有脚本放进项目根目录下的 run 脚本中,并在 .envrc 文件里加上这一行:

PATH_add "$PWD"

下次,只需要在任何地方运行 build-all 就行了。后面会详细讲到这一点。

不同目录使用不同配置

在某个目录下时,direnv 会寻找该目录中的 .envrc 文件并执行它。如果该目录没有 .envrc 文件,direnv 会沿着目录树向上查找,直到找到一个为止。这让我们可以为不同的子项目设置不同的配置。就像这样:

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

而在 js 目录下:

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

所以当我们在任意目录下运行 docker 时,它都会使用正确的 Docker 镜像和配置。

确保所有开发者使用相同的工具和版本

还记得我们说过,可以把任何命令放进 .envrc 文件吗?我们可以利用这一点,确保所有开发者使用相同的工具和版本。举例来说,我们可以检查是否安装了正确版本的 Go:

# .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

我们可以为项目中使用的 Node、Docker 或其他工具添加类似的检查。这确保了所有开发者使用相同的工具和版本,能够避免许多问题。

提示:把上面这些检查放进 go/.envrc 中,这样只有从事 Go 相关工作的开发者才需要安装 Go。我们总不想强迫前端开发者也去装 Go,对吧?

引入父级 .envrc 文件

当我们有一个包含多个目录的大型项目,不得不把这些 .envrc 文件复制(并维护)到每个目录时,就该重构了!我们可以不用复制,而是引入父级的 .envrc 文件。这非常有用,因为我们可以通过继承和覆盖来组织配置:共享配置有一个唯一的信息源,同时特定目录仍可以根据需要覆盖或扩展这些设置。

我们用 source 命令来引入父级的 .envrc 文件:

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

如果你想深入了解, direnv 还有 许多其他用例。现在,让我们转向 run 脚本。

run:一个用于管理项目中所有脚本的简单 bash 脚本

它并不是一个真正需要安装的工具,而是一种管理和执行脚本的模式。我们创建一个名为 run 的文件,和 .envrc 放在同一个目录下,把我们的脚本放进去。这个文件是一组可以从命令行运行的函数集合。它就像一个 Makefile,只不过是用 bash 写的。

一个示例

*一段代码胜过千言万语。*以下是我们项目中使用的 run 脚本示例:

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

把这个脚本以 run 为名保存在和 .envrc 相同的目录下。单独看的话,run 脚本本身什么都不做,它只是一堆函数的集合。魔法发生在最后一行,它 source 了 _cli.sh 脚本。

_cli.sh:让 run 脚本中的所有函数都可用

那么 _cli.sh 做了什么呢?它的任务是检测 run 脚本中所有可用的函数,并让你能够从终端运行它们。以下是 _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

我们来试一下:

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

不错!它列出了所有可用的命令,按名称排序。 我们来运行一个命令:

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

太漂亮了!它输出了 256 色模式下的所有颜色,以及对应的编码。
那如果一个命令不存在会怎样呢?

$ run x ERROR: run-x not found.

$ echo $? 123


哦!它显示了一条错误信息,并带上了退出码。
我们也能传递参数和变量吗?

$ run calc 2 ^ 16 65536

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


完美!现在我们可以往 run 里添加更多脚本了!🚀🚀

#### 解释:它是怎么工作的?

- run 脚本声明了我们想要运行的函数,这些函数以 run- 为前缀。
- \_cli.sh 脚本通过调用 compgen -A "function" 来检测 run 脚本中所有可用的函数。
- [compgen](https://tiswww.case.edu/php/chet/bash/bashref.html#index-completion-builtins) 是一个 bash 内置命令,用于为某个命令生成可能的自动补全选项。
- 我们把 compgen 的输出通过 grep 和 sed 处理,取出所有以 run- 为前缀的函数,并保存进 items 里,这就是可用命令的列表。之后我们就可以在 run help 中列出它们。
- 要运行一个命令,我们再次调用 compgen 检查该函数是否存在。如果存在,就用提供的参数调用它。如果不存在,就显示一条错误信息,并以代码 123 退出。

**Direnv 和 run 是一对强大的组合:**

- 没有 direnv 的话,我们需要用 ./run colors 来调用 colors 命令。如果我们在子目录下,就会变成 ../../../run colors。
- 得益于 direnv,我们可以在 .envrc 中使用 PATH\_add "$PWD",从而在任意子目录下都能直接调用 run colors。
- run-in-docker 函数是一个很好的例子,展示了如何把 direnv 和 run 结合起来使用。它利用 .envrc 文件中的 RELATIVE\_PATH 和 DOCKER\_IMAGE 变量,在 Docker 容器中运行命令。*还记得我们可以在不同的**.envrc 文件中为不同目录声明这些变量吗?*
- run-generate-all 函数展示了如何使用 direnv exec,并手动设置 RELATIVE\_PATH,以便在其他目录中运行命令。

### 在实践中使用 direnv 和 run

#### 预定义工具函数,并让它们始终对脚本可用

举例来说,我们在 \_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" ; }


然后我们就可以在脚本中使用它们:

run

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


### 强制开发者使用相同的工具和版本

这是一个真实的例子,展示了我们如何强制开发者使用相同的 Go 版本。我们把这个检查放进 \_cli.sh 中,这样每次开发者运行命令时,都会检查是否安装了正确版本的 Go:

_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


- [[ -e "$PROJECT\_ROOT/go" ]] 这个判断,只让从事 Go 相关工作的开发者需要满足这个要求。
- [[ "$PROJECT\_ROOT" != "/root/"\* ]] 这个判断,用于防止该检查在 Docker 容器中运行。

### 自动保持子模块的更新

很多时候,开发者会忘记更新子模块,导致运行依赖它们的脚本时出现问题。这可能要花上好几个小时去排查,或者提出问题后又要等上好几个小时才得到答案,结果才发现原来是忘记更新子模块了。😮‍💨

我们可以在 \_cli.sh 中添加一个检查,在必要时提醒开发者更新子模块:

_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


- 如果 git status 输出了 modified: tooling (new commits),说明子模块已经过期。脚本会在处理之前询问开发者是否要更新子模块。
- 它只检查 (new commits),因此子模块中的其他变更,比如未追踪的内容,或者 (new commits, modified content),都不会触发这条警告。

ask-yes-no 是一个简单的函数,用来向开发者请求确认:

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


### 自动配置本地环境

类似地,我们可以检查是否安装了 jq。如果没有,我们可以安装 gojq,并在 .bin 目录下为 jq 创建一个别名。
注意,我们需要在 .envrc 文件中使用 PATH\_add "$PROJECT\_ROOT/.bin",这样 jq 命令才能被找到。

_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


### 加载敏感密钥

有些命令需要敏感密钥才能运行。我们可以创建一个工具函数,从 AWS Secrets Manager 中加载这些密钥,供其他脚本后续使用:

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


### 利用哈希在必要时自动重新构建命令

假设我们有一个自定义 CLI 工具 .bin/mycli,是我们脚本中会用到的。我们可以计算该工具源码的哈希值,并与本地已安装工具的哈希值进行比较。如果两者不同,脚本会自动重新构建该工具,并更新本地的哈希值。

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

}


### 结语

把 direnv 和 run 脚本整合进我们的开发工作流之后,我们打造出了一个强大、高效、一致的环境,极大地惠及了整个团队。你也一样可以做到!💪💪

它们易于配置和使用,能为你和你的团队节省大量时间和精力。有了 direnv,你可以为项目配置环境变量,并让它们在所有脚本中可用。有了 run,你可以创建一组可以从命令行运行的函数,并针对每个目录进行细致调整。两者结合,可以帮助你和团队更高效地编写和管理脚本!🚀🚀

### 作者

*我是 Oliver Nguyen —— C*[*onnectly.ai*](https://connectly.ai)*的一名软件工程师。我喜欢不断学习,每天都希望看到更好的自己。偶尔会分拆出一些新的开源项目。在旅程中分享知识和想法。*

*本文同时发布于* [*olivernguyen.io*](https://olivernguyen.io/w/direnv.run/)*。*