作者:Oliver Nguyen

在 JavaScript/Node 的世界里,我们通常把脚本存放在 package.json 中,用 npm 来运行它们。在其他世界里,我们会用 Makefile,或者建一个目录把所有脚本放进去。但对于团队来说,管理和运行脚本还有更好的方式。不,我说的不是 Warp 或其他花哨的工具。
我要分享的是 direnv 以及我们的老朋友 bash:我们如何用它们作为一个团队高效地编写和管理脚本。
简单介绍一下,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 文件。这非常有用,因为我们可以通过继承和覆盖来组织配置:共享配置有一个唯一的信息源,同时特定目录仍可以根据需要覆盖或扩展这些设置。
我们用 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 的文件,和 .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 的内容:
#!/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
```
太漂亮了!它输出了 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 脚本中定义几个用于给文本上色的函数:
t-color() { printf "\e[38;5;%dm" "$1" ; } t-yellow() { t-color 3 ; } t-reset() { printf "\e[0m" ; }
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-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:
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 中添加一个检查,在必要时提醒开发者更新子模块:
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 是一个简单的函数,用来向开发者请求确认:
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 命令才能被找到。
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/)*。*