我上周刚拿到一台新开发机,装完系统以后光把 Node、Java、Go、Docker 这些轮子配齐,来回切窗口、找安装包、改 PATH、翻报错,就折腾了大半个下午。这不是第一次了。所以第三次重复做这件事的时候,我实在没忍住,把整套路子抽成了一个脚本:d.sh,一个用纯 bash 写成的、把整个 Coding Agent 体系塞进同一个文件里的配环境 Agent。
它解决的事情非常具体:在一台新机器上,自动完成“系统探测 → 工具检测 → 缺失安装 → 环境变量配置 → 二次验证”这条链路的全部动作。你只需要一行 curl 拿到脚本,再执行./d.sh auto,剩下的活儿它自己安排。
这篇文章写给三拨人:被配环境反复折磨的开发者、想用 bash 做点正经自动化的运维/后端、以及对“最小 Agent 实现”感到好奇的人。我会把设计思路、完整模块拆解、真实踩坑,以及最后的使用效果都摊开讲。如果你只是想要一个能直接抄的文件,我也把关键代码贴在对应小节里,按顺序拼起来就是一个能跑的 d.sh。
1. 为什么非要用 bash 做一个配环境 Agent
1.1 配环境不是“装软件”,而是一棵决策树
很多人觉得配环境就是“下一步下一步装软件”,其实不是。它本质是一个条件判断特别多的决策过程:
- 先判断这台机器是什么系统,Linux、macOS 还是 Windows 下的 Git Bash;
- 判断系统里装了哪些包管理器,apt、dnf、brew 还是 winget;
- 逐个检查目标工具在不在,在的话版本够不够;
- 不在就安装,装完还要配置 PATH、JAVA_HOME 之类的环境变量;
- 最后重新检测一遍,确认刚才的动作真的有效。
这五步正好对应一个 Agent 的经典循环:感知(探测环境)、决策(判断缺什么)、行动(安装配置)、验证(二次检测)。所以从思路上说,把配环境脚本做成一个 Agent 不是夸张,而是这类任务天然适合。
问题是大多数人配环境的时候是“人肉 Agent”:眼睛看报错,大脑查 Google,手上敲命令。一次两次还好,换到第三台、第四台机器,或者帮团队新人配环境的时候,这种重复劳动就很浪费了。
1.2 为什么是 bash,而不是 Python、Go 或 Ansible
我在最初设计方案时不是没想过用“更正经”的语言,但逐个排除后还是回到了 bash。理由其实很朴素:
- Python 有“先有鸡还是先有蛋”的尴尬:配环境的场景里,新机器有可能连 Python 3 都没有,或者系统自带的 Python 版本乱七八糟。你为了配环境先去配 Python 环境,等于问题没解决还多了一层依赖。bash 则不一样,Unix 系系统自带,Git Bash 装一个就能在 Windows 上用,几乎零前置条件。
- Go 编译出来的二进制确实方便分发,但改一次逻辑就要重新编译一次,对快速迭代不友好。而且配环境这个场景大部分操作就是调用系统命令,用 Go 去包一层反而增加体积和复杂度。
- Ansible 适合批量管理几十台机器,但在这台开发机上只是自用的话,引入 Python 环境和 playbook 目录结构太重了。一个文件能解决的问题,搞出一堆 yaml 文件反而难维护。
bash 最大的优点是:系统自带、语法简单、和命令行的距离最近。它的缺点——字符串处理别扭、没有原生数据结构、容易写出不可维护的“屎山”——完全可以通过结构化函数设计和命名约定来规避。说到底,配环境这个任务的复杂度是有限的,用一个轻量工具去做恰好合适。
1.3 d.sh 的“Agent 循环”:感知、决策、行动、验证
真正让我觉得“这玩意儿配得上 Agent 这个名字”的,是它的主循环设计。d.sh 的内部不是一坨顺序执行的命令,而是把一个状态机塞了进去:
当前状态 = 初始 循环: 感知:收集系统信息、工具状态 决策:比对目标清单,找出缺失项 行动:执行安装或配置动作 验证:重新收集状态,确认是否收敛 若还有缺失项,继续循环;否则退出因为没有真正的 AI 模型在跑,这个 Agent 本质上是一个确定性有限状态机。它不会遇到问题就“想”出计划外的方案,但它能把整个配环境流程自动化地收敛到一个可预期的结果。对配环境这个领域来说,这已经够用了。
我把这个循环拆成了五个子命令,让每种使用需求都有对应的入口。下一部分细讲。
2. d.sh 的功能边界与命令设计
2.1 命令设计:check、install、doctor、auto、list
d.sh 采用类 git 的子命令风格,一共五个命令,每个命令对应一种完整的使用场景:
| 命令 | 行为 | 典型场景 |
|---|---|---|
./d.sh list | 列出当前支持的全部工具清单 | 刚拿到脚本,先看看它能管啥 |
./d.sh check | 只读检测,输出每项工具的状态和版本 | 检查一台机器还缺什么 |
./d.sh install <tool> | 安装或配置指定工具 | 只想补某一个缺失项 |
./d.sh doctor <tool> | 对一个工具做深度诊断 | 工具存在但用不了,查原因 |
./d.sh auto | 自动完成检测、安装、验证的完整闭环 | 新机器一键配环境 |
设计原则非常明确:默认只读,写入必须有显式意图。也就是说,check永远只打印信息,绝不改动系统;只有输入install或auto才会真正调包管理器。这个约定很重要,既能防止手滑执行了不该执行的命令,也让新人敢在陌生机器上先跑一遍check看状态。
doctor是我后来补的一个命令。因为实际使用中发现,很多“配不上环境”的坑不是工具没装,而是装了但 PATH 不对、版本冲突、符号链接断了。doctor会打印工具的安装路径、软链情况、版本号、以及 rc 文件里相关的配置,一次给足排查信息。
2.2 工具清单的登记方式:约定优于配置
bash 没有像样的数据结构,所以我没有搞一个复杂的配置表,而是用了一套命名约定来组织工具信息。每个工具在d.sh里对应三样东西:
- 一个数组元素,表示工具名;
- 一个
tool_check_xxx()函数,负责检测; - 一个
tool_install_xxx()函数,负责安装配置。
主程序在 dispatch 的时候,直接用工具名拼函数名动态调用。比如要检测 node,就执行tool_check_node,检测逻辑在函数里自己写。
# 工具注册表 TOOLS=(git curl wget node python3 go java docker) # 按名字执行检测函数 dispatch_check() { local tool="$1" # 通过函数名拼接实现“多态” if declare -F "tool_check_${tool}" >/dev/null 2>&1; then "tool_check_${tool}" else log WARN "工具 ${tool} 还没有对应的检测函数" fi }我特意没有用 bash 4 的关联数组(declare -A),因为 macOS 自带的 bash 还是 3.2,关联数组会直接报错。用“数组 + 命名约定”这种方式,虽然老套,但兼容性极好,Git Bash、macOS、Linux 全都能跑。这也是单文件脚本必须考虑的妥协。
新增一个工具的流程被压缩到两步:在TOOLS数组里加个名字,然后写两个函数。听起来简单,实际用下来也确实简单。我自己从最开始的 5 个工具扩到现在 12 个,没有因为新增工具而改过主逻辑。
2.3 输出与日志:终端友好、机器可读、事后可查
脚本的输出质量和日志能力决定了工具“好不好用”。d.sh 有三层输出:
第一层是终端彩色输出。INFO 用绿色,WARN 用黄色,ERROR 用红色,一眼扫过去就能看出当前卡在哪。但颜色输出有个坑:当 stdout 被重定向到文件或管道时,转义序列会变成乱码。所以我在日志函数里做了一次判断:
log() { local level="$1"; shift local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" if [[ -t 1 ]]; then # stdout 是终端时才输出色彩 case "$level" in INFO) printf '\033[0;32m[INFO]\033[0m %s %s\n' "$ts" "$*" ;; WARN) printf '\033[0;33m[WARN]\033[0m %s %s\n' "$ts" "$*" ;; ERROR) printf '\033[0;31m[ERROR]\033[0m %s %s\n' "$ts" "$*" ;; esac else printf '[%s] %s %s\n' "$level" "$ts" "$*" fi printf '[%s] %s %s\n' "$level" "$ts" "$*" >> "$LOG_FILE" }[[ -t 1 ]]的作用是判断文件描述符 1 是否连接到终端。在终端跑就显示颜色,接管道就有干净的纯文本。同时每一行都会附加时间戳追加到日志文件里,日志文件路径放在${TMPDIR:-/tmp}下,按时间戳命名,方便事后排查。
这套输出设计让我在使用时省了不少心。./d.sh check的输出可以直接grep或cut处理,出问题又能去日志文件里翻完整时间线。
3. 核心模块实现:从探测到安装的完整链路
3.1 跨平台系统探测与包管理器分发
配环境 Agent 的第一步是搞清楚自己在哪。uname -s是跨平台探测的基础,我用一个 case 分支把系统归成三类:
detect_os() { case "$(uname -s)" in Linux*) echo "linux" ;; Darwin*) echo "macos" ;; MINGW*|MSYS*|CYGWIN*) echo "windows" ;; *) echo "unknown" ;; esac }Windows 这里需要特别说明:Git Bash 底层是 MSYS2 或 Cygwin 模拟层,所以uname -s会返回MINGW64_NT之类的字符串。把它识别成windows后,后面很多系统调用行为就和 Linux 不一样了,比如安装命令要走 Windows 的包管理器,而不是 apt。
识别完系统,紧接着是识别包管理器。这一步直接决定后续所有安装命令怎么发:
detect_pm() { case "$OS" in linux) if command -v apt-get >/dev/null 2>&1; then echo "apt" elif command -v dnf >/dev/null 2>&1; then echo "dnf" elif command -v yum >/dev/null 2>&1; then echo "yum" elif command -v pacman >/dev/null 2>&1; then echo "pacman" else echo "unknown"; fi ;; macos) if command -v brew >/dev/null 2>&1; then echo "brew" else echo "missing-brew"; fi ;; windows) if command -v winget >/dev/null 2>&1; then echo "winget" else echo "unknown"; fi ;; esac }有了OS和PM两个变量,后面写安装函数就简单了。每个工具的安装函数内部做一个包管理器分发,比如install_node里就是:
- apt 系执行
sudo apt-get install -y nodejs npm - brew 执行
brew install node - winget 执行
winget install OpenJS.NodeJS
这个分发逻辑看起来琐碎,但它是全脚本机制的核心。没有它,同样的功能就得写三套完整的安装路径,脚本体积会膨胀一倍。
3.2 检测函数:command -v与版本解析
检测一个工具是否安装,最可靠的不是which,而是command -v。which在很多系统上不是标准命令,不同平台的实现行为不一致,在某些 shell 环境里还会拖慢启动。command -v是 POSIX 标准自带的,行为统一,判定结果干净。
tool_check_node() { if command -v node >/dev/null 2>&1; then local ver ver="$(node --version 2>/dev/null | sed 's/^v//')" log INFO "node 已安装,版本 ${ver}" echo "${STATUS_FOUND}" else log WARN "node 未安装" echo "${STATUS_MISSING}" fi }版本解析这里有个容易被忽略的点:像node --version输出的是v20.11.0,前面带个v。直接拿去比较版本号会出问题。我的做法是用sed 's/^v//'把前缀剥掉,然后交给统一的版本比较函数。
比较版本高低时又有个兼容性坑。GNU 的sort -V能直接比较版本号,但 macOS 自带的 BSD sort 不支持-V参数。所以我在 d.sh 里写了个简单的版本字符串比较函数,用按点拆分的数字逐个比,彻底摆脱对sort -V的依赖。这个细节如果不处理,在 macOS 上脚本会直接报invalid option -- V。
3.3 安装与配置分离的实践
我在设计工具函数时,刻意把“安装”和“配置”拆成了两个逻辑阶段。以 Java 为例,apt-get install openjdk-17-jdk装完以后,还需要设置JAVA_HOME环境变量,否则很多构建工具找不到 JDK。安装动作和配置动作的触发条件不同:
- 安装只发生在“命令不存在”时;
- 配置则每次都会检查 rc 文件里有没有对应条目,没有才补充。
配置函数的实现关键在幂等性。往~/.bashrc或~/.zshrc里追加内容之前,一定要先判断内容是否已经存在,否则每次跑auto都会重复插入一行,越插越多。
ensure_rc_line() { local rc_file="$1" local line="$2" if [[ -f "$rc_file" ]] && grep -qF -- "$line" "$rc_file" 2>/dev/null; then log INFO "${rc_file} 已包含配置,跳过" return 0 fi printf '\n# added by d.sh\n%s\n' "$line" >> "$rc_file" log INFO "已写入 ${rc_file}: ${line}" }这条grep -qF判断是整个配置模块的护城河。-F把匹配模式当作固定字符串,避免JAVA_HOME里的$符号被当成正则处理,从而误判或漏判。我自己在这个地方犯过两次错,一次没加-F导致包含特殊字符的行永远匹配不上,一次忘了判断直接追加导致 rc 文件被刷屏。
3.4 环境变量持久化的处理
环境变量持久化是配环境里最容易被忽视的一环。很多人装完 Java 发现java能跑,但JAVA_HOME没生效,这就是配置阶段没做好。
d.sh 的原则是:优先写用户级 rc 文件,不碰系统级文件。具体做法是先探测当前用户用的是 bash 还是 zsh,然后往对应的 rc 文件里写。Git Bash 环境下还要额外处理 Windows 风格的路径转换,比如C:\Program Files\Java\jdk-17要转成/c/Program Files/Java/jdk-17才能被 bash 识别。
写完之后脚本会明确提示用户执行source ~/.bashrc或重开终端。这个提示不能省,因为一个会话里已经加载的环境变量不会因为 rc 文件改变而自动刷新,很多新手在这一步会以为配置失败。
3.5 auto 全流程的组装
auto命令是 d.sh 的最终形态,它把前面所有模块串起来。执行流程如下:
- 打印本次要处理的目标工具清单;
- 对所有工具做一轮
check,收集缺失项; - 如果缺失项为空,直接输出“环境已就绪”并退出;
- 逐个调用缺失工具的安装函数;
- 全部装完后,再做一轮
check,对比前后状态; - 输出最终摘要:哪些工具搞定,哪些工具仍然失败。
第一次跑auto时最怕中途某个工具安装失败导致后面全乱。所以 d.sh 在循环里对每个安装函数单独捕获返回值,失败就记入“失败列表”,继续装下一个。全部跑完后统一报告,而不是一遇到错误就退出。这个设计让脚本在一台缺很多东西的机器上也能尽量收敛——装不上的可以在最后看清单手动处理。
4. 换行符、返回值与幂等性:四个真实踩坑记录
4.1 /bin/bash^M: bad interpreter——Git Bash 里最常见的换行符事故
d.sh 在团队里流传开后,出现频率最高的报错是/bin/bash^M: bad interpreter: No such file or directory。这个^M不是脚本内容问题,而是文件里的换行符是 CRLF,不是 Unix 标准的 LF。bash 在执行脚本时,把行尾的\r当成了解释器路径的一部分,自然找不到。
原因几乎都出在 Windows 上。用 Visual Studio Code 或记事本编辑过脚本后保存,文件被写成了 CRLF;或者 Git 在 clone 仓库时因为core.autocrlf配置把 LF 自动转成了 CRLF。解决方法不复杂,把文件转回 LF 就行:
# 命令行直接清掉行尾的 \r sed -i 's/\r$//' d.sh但“能修”和“不再踩”是两码事。我的建议是双管齐下:在项目根目录放一个.gitattributes文件,强制 shell 脚本以 LF 存储;同时在编辑器里把默认换行符设成 LF。如果你只是下载了 d.sh 单文件,没有仓库,那就记得下载后用sed -i 's/\r$//' d.sh处理一遍再运行。
4.2 Git Bash 的 crontab“失踪”与 PATH 的差异
另一个频繁被误报的场景是-bash: crontab: command not found。很多人以为 Git Bash 是“Windows 里的 Linux”,所以 Linux 上有的命令它都应该有。事实是 Git Bash 只提供了常用的 GNU 工具集,crontab、systemctl、service这类系统级命令是不存在的。
这对 d.sh 的设计有一个直接影响:在工具检测清单里,它把 cron 相关的检查标记为“可选项”,而不是“必选项”。如果检测到crontab不存在,只输出 WARN 而不是 ERROR。因为对 Windows 用户来说,正确的做法是去任务计划程序里配定时任务,而不是纠结 Git Bash 里为什么没有 crontab。
这个案例给我最大的教训是:跨平台脚本里对“命令是否存在”的判断,必须结合平台语义。同样的命令在 Linux 上是基础设施,在 Git Bash 里可能压根不属于它该管的事。
4.3 set -e 与command -v的返回值冲突
bash 脚本最佳实践通常建议开启set -e,让任何命令失败时立即退出。但做环境检测时,set -e和command -v之间存在一个隐蔽的冲突:当工具不存在时,command -v会返回非零状态,在set -e模式下直接让脚本退出。
我第一次写检测函数就栽在这上面。tool_check_node里只要 node 没装,command -v node返回 1,整个脚本就停了,后面的安装逻辑根本没机会执行。
解决办法有两种。一种是像前面代码里那样,把command -v放进if的条件里,bash 对if条件中的命令不启用set -e:
if command -v node >/dev/null 2>&1; then # 已安装分支 else # 未安装分支 fi另一种是在特定位置临时关闭set -e,用完后恢复。我实际写 d.sh 时两种都用了,但主力方案是第一种,因为更安全,不会出现“忘了恢复”导致后续一整段命令都不检查退出状态的情况。
4.4 颜色输出在管道与日志文件中的兼容处理
前面提过[[ -t 1 ]]这个判断。它解决的实际问题是:当./d.sh check | grep node这种管道执行时,如果脚本输出里带了 ANSI 颜色转义序列,grep匹配到的内容会包含一堆\033[0;32m之类的垃圾字符,肉眼几乎没法看。
更好的处理是兼容NO_COLOR环境变量。有些终端环境或 CI 系统默认设置NO_COLOR=1来关闭所有颜色输出,脚本如果不认这个约定,在 CI 日志里就会刷屏转义字符。所以我在颜色判断里加入了NO_COLOR检查:
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then # 启用颜色 else # 纯文本输出 fi4.5 重跑脚本的幂等性设计
配环境脚本一定会被反复执行:第一次跑到一半停了,第二次补跑;或者这次新增了几个工具,在已有环境上再跑。如果脚本不具备幂等性,第二次跑就会制造一堆重复配置。
d.sh 的幂等性主要靠两个机制保证。
第一是“安装前先检查状态”。auto在执行安装函数之前,会先确认目标工具确实缺失。已经存在的工具直接跳过,不会重新下载重新安装。
第二是“配置写入前先查重”。ensure_rc_line函数用grep -qF判断目标行是否已经在文件里,存在就跳过。这样无论跑多少遍,rc 文件里跟 d.sh 相关的内容永远是那几行,不会指数级膨胀。
还有一个方向是并发保护。虽然正常不会同时跑两个 d.sh,但为了防止极端情况下两个进程同时写 rc 文件,我加了个简单的锁文件机制:执行auto前先创建一个临时目录作为锁,结束或异常退出时清理。这个锁不复杂,属于“花了 5 分钟写但可能省不少麻烦”的投资。
5. 实测效果:从新机器到可开发状态的用时对比
5.1 一次新机器配置的实测时间线
上个月我在一台全新的 Ubuntu 24.04 开发机上跑了完整流程,记录如下:
| 步骤 | 手动操作(估算) | d.sh 操作 |
|---|---|---|
| 系统探测与包管理器确认 | 5 分钟(还要查命令) | 1 秒 |
| 检查 12 个工具缺少哪些 | 10 分钟(挨个敲命令) | 3 秒 |
| 安装缺失工具 | 25 分钟(找包名、装错重来) | 10 分钟(自动安装) |
| 配置环境变量 | 10 分钟(不同工具不同写法) | 5 秒 |
| 二次验证 | 5 分钟 | 3 秒 |
从 55 分钟压到 15 分钟左右,关键是过程中不需要我盯着输出。真正省下来的是“思考下一步要做什么”的时间:脚本自己知道下一步该测什么、装什么、配什么。对经常换机或者带新人的场景,这个收益非常可观。
那次实际跑的时候有两个工具安装失败。一个是 Docker,因为新版 Docker Desktop 在 Ubuntu 上的安装方式改了包名;另一个是 Go 的版本冲突。d.sh 把这两个标成失败,打印出安装链接,我手动处理掉以后重新跑了一遍./d.sh check,状态就全部绿了。这个“部分失败但不中断”的设计在真实场景里尤其重要。如果脚本一遇错就退出,前面装好的东西也会让人不安,后面复跑时还得重新检测。
5.2 扩展建议:CI 配置检查器与团队内部分享
d.sh 的使用场景不止新机器。我后来发现,把它丢进 CI 里当“环境检查器”也很顺手。GitLab CI 或 GitHub Actions 的任务里加一步./d.sh check,如果输出里有STATUS_MISSING,就让 job 失败。这样任何一次构建跑起来之前,都能先确认构建机上的工具链是完整的,避免跑到一半才发现缺依赖。
团队场景下,d.sh 的价值还体现在“统一配置口径”上。新人入职配环境这件事,传统做法是拉一个文档让新人自己跟着操作,文档版本一多就乱。用 d.sh 之后,所有工具的版本、安装方式和配置项都收敛在一个文件里,新人只需要克隆脚本、跑auto,出现解决不了的问题再找人对症下药。排障效率高很多,因为脚本的输出本身就是线索。
当然,d.sh 的适用边界很明显:它面向开发机的单机场景。如果你要管几十台服务器的环境一致性,Ansible 或 Nix 是更合适的选择。工具选型不是越重越好,而是匹配问题规模。d.sh 适合的是“一台一台逐台配”的人和团队。
5.3 什么时候该停手:d.sh 的边界与我的取舍
写这个脚本的过程里,我无数次想把它做得更“聪明”:加上工具间的依赖关系、写一个交互式菜单、做一个内嵌的版本更新逻辑。最后都忍住了。原因是,每加一个复杂特性,脚本的体积和心智负担都会上一个台阶,而配环境这个场景的收益却不大。
我的取舍标准很简单:如果一件事用三五行 bash 能描述清楚,就留在 d.sh 里;如果它开始需要一个配置文件、一个状态数据库、或者一个图形界面,那它已经超出单文件 bash 脚本的合适边界了。目前 d.sh 稳定在 900 行左右,覆盖 12 个常用工具,新开发机上 80% 的环境配置需求都能覆盖,剩下 20% 的手工处理场景,脚本会把失败项和参考链接明明白白列出来,让开发者知道下一步该干嘛。
这就是我眼里一个“单文件 Coding Agent”该有的样子:有感知,有决策,有行动,有验证,但始终知道自己边界在哪。工具再小,能稳定解决问题的部分就是一个合格的 Agent。d.sh 不一定能帮你配完所有环境,但至少能让你少折腾大半个下午。