外部命令改不了你的 shell:sdkm 怎么做到"回车即生效"

0 阅读2分钟

先看一个所有开发者在终端里撞过、但不一定想透过的现象:

$ fake_setenv FOO hello    # 假设这个工具内部真的调用了 setenv("FOO", "hello")
$ echo $FOO                # 空。无论工具内部多么"成功"

这不是哪个工具写得烂,是 Unix 的物理定律:execve 启动子进程时,交给它的是当前环境的快照副本。子进程改的永远是副本,进程退出,修改随之销毁——父 shell 的环境从头到尾没人碰过。

这条定律折磨版本管理器折磨了半个世纪。你用的每一个版本切换工具,本质上都在回答同一道题:一个外部命令,怎么改变它所在 shell 的环境?

上一篇(《我把 nvm、jenv、pyenv 全卸了》)展示了 sdkm 的现象:切完版本按一下回车就生效、进目录自动切、改 .sdkm.toml 立即热更新。这篇把机制拆开——先说清楚这道题业界怎么解,再看 sdkm 的解法在源码里长什么样,最后聊聊这条路的代价。

仓库:github.com/borenchan/s… (Rust / Apache-2.0 / 文档全中文)

一、三条路线,两种妥协

面对"改不了父 shell"这道题,业界成熟路线有两条半:

路线 A:把工具变成 shell 函数。 nvm、jenv、pyenv 全是这条路的——nvm 不是一个二进制,是定义在你 shell 里的函数,函数在自己的进程里 source 生效,所以能改环境。代价:每种 shell 重写一遍函数逻辑,shell 脚本里混着版本解析、下载、安装的业务逻辑,维护者在 POSIX 兼容性的泥潭里扑腾。Windows 的 PowerShell 还得另养一套。

路线 B:shims 垫片。 asdf、mise、rye 这类工具把一个"垫片目录"塞进 PATH 最前面,node 实际是垫片脚本,它读当前目录配置、转发给真正的二进制。环境不用改了,但代价转嫁到了每次执行:which node 拿到的是垫片路径不是真路径,IDE 和构建脚本探测工具链时容易出幺蛾子,每次执行还多一跳。

路线 C:prompt hook + eval。 shell 每次渲染提示符时,回调一个外部命令,让它输出"此刻应有的环境脚本",shell 自己 eval 进去。direnv 是这个思路的代表。

sdkm 选了 C。理由有三个,都跟我的处境有关:

  1. Windows 一等公民是核心卖点,而 Windows 上 shell 函数生态和 shim 脚本兼容性都是苦活,PowerShell 的事件机制反而干净;
  2. 4MB 单二进制不想掺任何 shell 脚本业务逻辑,sdkm 这个可执行文件始终只是个可执行文件;
  3. 路径透明——which java 永远拿到真实符号链接路径,IDE 直接受益。

但路线 C 有个绕不开的账:每次回车都要跑一次子进程。这笔账怎么还,第四节讲缓存时算。

二、hook 脚本逐行拆

sdkm init 往你的 profile 里注入一行:

eval "$(sdkm hook bash)"

sdkm hook bash 输出什么?直接看源码(crates/util/src/shell_backend/bash.rs;zsh/fish/PowerShell 各有一份同构模板:precmd / fish_prompt / prompt 函数,见各自文件):

# sdkm hook -- bash
# Re-evaluate on every prompt render (hot-reload: editing .sdkm.toml then Enter applies; perf via sdkm env mtime cache)
_sdkm_hook() {
    eval "$(sdkm env --shell bash)"
}
# Save startup PATH once (skip if already set, so re-sourcing the profile keeps base)
[ -z "${_SDKM_BASE_PATH:-}" ] && export _SDKM_BASE_PATH="$PATH"
# Register into PROMPT_COMMAND (dedup)
case ":${PROMPT_COMMAND:-}:" in
    *":_sdkm_hook:"*) ;;
    *) PROMPT_COMMAND="_sdkm_hook${PROMPT_COMMAND:+:$PROMPT_COMMAND}" ;;
esac
_sdkm_hook

四段逻辑,每段都有讲究:

  1. _sdkm_hook 函数本体:每次触发就干一件事——eval "$(sdkm env)"。注意这里把改环境的执行权交还给了 shell:sdkm 进程只负责输出脚本文本,eval 是 shell 自己在执行。物理定律没被打破,是换了个人动手。
  2. base 快照:把 shell 启动时的 PATH 存进 _SDKM_BASE_PATH,守卫条件 [ -z ... ] 保证重复 source profile 不会覆盖第一次的快照。后面会看到,这个变量是"幂等重建"的锚点。
  3. 去重注册:case 模式匹配查重,sdkm hook bash 被多次 eval 也不会往 PROMPT_COMMAND 里堆重复项。
  4. 立刻跑一次:注入完不用等下一次回车,当前环境马上就位。

zsh 挂 precmd,fish 挂 --on-event fish_prompt,PowerShell 包装 prompt 函数——四个 shell 四个事件源,同一个协议。

故意不做的优化:PWD 短路

做"进目录切环境"的工具,几乎都会加一个直觉优化:记下上次触发时的目录,PWD 没变就跳过(direnv 系就是这么干的)。sdkm 的 hook 里故意没有这个短路,而且源码里有一条测试专门防着有人手滑加上:

// crates/sdkcore/src/shell/hook.rs
#[test]
fn hook_scripts_reference_env_names() {
    for shell in Shell::ALL {
        let script = generate_hook_script(shell);
        assert!(script.contains(ENV_HOOK_BASE_PATH), "{shell:?} 缺 BASE_PATH 保存");
        assert!(script.contains("sdkm env"), "{shell:?} 缺 env 调用");
        // 每次提示符渲染都触发(无 PWD 比对短路——热更新依赖)
        assert!(!script.contains("LAST_PWD"), "{shell:?} 不应含 LAST_PWD 比对");
    }
}

为什么?想想热更新的场景:你站在项目目录里手动把 .sdkm.toml 里的 java = "8" 改成 "21",然后敲回车——PWD 没变。如果 hook 有 PWD 短路,这次修改要等你切走再切回来才生效,"热更新"三个字就是假的。sdkm 的取舍是:正确性不靠跳过,性能不靠短路——每次都触发,但用缓存把每次触发的成本打到毫秒级。设计决策用测试钉死,这是我觉得写 Rust 最爽的地方。

三、eval 进来的是什么:四行脚本,四层优先级

sdkm env 的输出长什么样?bash 版的真实生成逻辑(golden test 逐字原样,crates/sdkcore/src/shell/env.rs;bins 为测试示意值,实际是 store 版本目录):

[ -z "${_SDKM_BASE_PATH:-}" ] && export _SDKM_BASE_PATH="$PATH"   # ① base 自愈
export PATH="bin/a:bin/b:$_SDKM_BASE_PATH"                        # ② PATH 重建
unset EXTRA                                                       # ③ 幂等还原
export JAVA_HOME="/sdk/java"                                      # ④ 选中导出

行序固定:自愈 → PATH → unset → export(源码里就是四个 fn 按序拼接,四个 shell 各自实现语法)。

  • ① base 自愈:万一你在没跑过 init 的 shell 里手动 eval 了 env 脚本,先兜底锚定 base,防止后面 PATH 重建把 PATH 搞丢。
  • ② PATH 重建:不是在现有 PATH 上追加,而是从 _SDKM_BASE_PATH 整个重建——export PATH="<选中的 bins>:$_SDKM_BASE_PATH"。这就是幂等:连续 eval 一百次,PATH 里也不会堆出一百份重复条目;离开项目目录,重建结果自动还原成 base 本身。
  • ③ 幂等还原:这行是 known-set 技巧,单独说。
  • ④ 选中导出:本轮选中的 SDK 该 export 的变量(JAVA_HOME 这类 extra_vars)。

③ 的玄机:unset 靠"全集减选中"

离开项目目录时,JAVA_HOME 是怎么自动消失的?笨办法是进目录时记下每个变量的旧值、离开时恢复——状态一多就乱。sdkm 的做法是无状态重建:把全局 config 里所有 SDK 声明过的 extra_vars 取个并集(known 集合),本轮没选中的 key 统统输出一行 unset。known 里有、选中集里没有 → unset 行出现 → 变量消失。整个"进入/离开项目"的还原语义,不依赖任何历史状态,每一步都是纯函数。

四层优先级

PATH 里放谁的 bins、export 哪些变量,由三层解析决定(源码注释原话:"会话层最高 → 项目层 pins → 全局层"),加上 base 快照兜底,合起来四层:

优先级层谁设置载体
1会话 · 当前终端sdkm use --shell java 21环境变量 SDKM_ACTIVE_JAVA
2项目 · 当前目录及子目录sdkm use java 21.sdkm.toml 的 pin
3全局 · 整台机器sdkm switch java 21config 的 current_version → 符号链接目录
4兜底——_SDKM_BASE_PATH 启动快照

源码里解析顺序(generate_env_script_inner):先扫会话层 SDKM_ACTIVE_<SDK>,已设的进选中集;再读 .sdkm.toml,被会话层覆盖的跳过;最后补全局层,被前两层覆盖的跳过。每层只增不覆盖,优先级天然成立,不用比较逻辑。

两个实战细节:

  • 未装降级:项目层 pin 了 java = "21" 但 21 没装?select_sdk_envs 返回 None,诊断走 stderr,该 SDK 回退全局层——配置先声明、版本后补装,不会因为一个没装的版本把整个 shell 搞坏;
  • PATH 首位生效:选中的 bins 按"会话 → 项目 → 全局"顺序拼在 PATH 最前面,PATH 查找先到先得,所以临时会话压过项目 pin、项目 pin 压过全局。

四、性能:五重指纹的缓存

现在还那笔账:每次回车跑一次 sdkm env 子进程,还要读 config、扫项目配置、解析版本——不慢吗?

答案在 crates/sdkcore/src/hook_cache.rs(207 行,缓存文件 <sdkm_home>/.cache/hook_cache.json,key 就是 PWD)。命中条件不是"PWD 没变"这么粗,而是五重指纹全对:

pub struct HookEntry {
    /// 命中的 `.sdkm.toml` 绝对路径(无项目配置时空串 = 锚定「无配置」状态)
    pub config_path: String,
    /// 纳秒 mtime(同秒连续编辑也能感知变化——热更新验收点)
    pub mtime_nanos: u128,
    /// 生成好的完整 eval 脚本(PATH 重建 + unset + export),命中直接吐
    pub env_script: String,
    /// 生成此条目时的 schema 版本(不匹配 = 旧格式,当 miss 重建)
    pub schema_version: u128,          // 当前 = 5
    /// shell 判别序号:防「同一 PWD 跨 --shell 拿到上一 shell 脚本」的串扰
    pub shell: u8,
    /// 会话指纹:所有 SDKM_ACTIVE_* 变量按名排序拼接
    pub session_fingerprint: String,
    /// 全局指纹:symlink_dir + 各 SDK current_version 集合
    pub global_fingerprint: String,
}

每一重指纹都对着一个真实会翻车的场景:

  1. .sdkm.toml 的 mtime,精确到纳秒——秒级时间戳有"同一秒内连续编辑"的盲区,热更新的验收点就是"改完立刻回车必须生效",所以用纳秒;
  2. shell 序号——同一个目录可能被 bash 和 PowerShell 同时打开(Windows 双终端常态),缓存 key 只有 PWD 的话,后开的 shell 会吃到前一个 shell 语法的脚本;
  3. schema 版本——脚本生成逻辑升级时 bump(现在是 5),旧缓存整体失效,不用写迁移代码;
  4. 会话指纹——中途跑 sdkm use --shell java 21 改了 SDKM_ACTIVE_*,指纹变,缓存 miss 重算。源码注释原话:不做这条的话"会话 > 项目的优先级文档会被缓存打破";
  5. 全局指纹——sdkm switch 改了 current_version,指纹变,重算。否则"switch 后按回车不生效",这是会挨骂的 bug。

这套指纹的妙处在于:所有状态变更都自动体现在指纹里,不管变更是 sdkm 自己做的(switch)还是你手改的(编辑 toml),不用任何"通知缓存失效"的主动逻辑。

兜底纪律也写在注释里:缓存文件损坏 → 删掉当空表(自愈);读写任何失败 → 静默降级实时解析。一句话原则:缓存是纯优化,坏了一条腿不能走路变瘸两条。另外全局指纹每次进程只算一次、判据和回写共用——config.toml 的读取是这条热路径上唯一新增的 IO。

五、两个被测试钉死的跨 shell 坑

四个 shell 的 hook 模板里还埋着两个脏细节,文档不会写,都是踩出来的,而且都进了测试:

fish 必须用 | source,禁用 eval (。fish 的命令替换会把多行脚本按换行拆成参数、再用空格拼成一行——注释吞掉后面的行、if...end 结构被打碎、多行 set 静默合并。所以 fish 的 hook 是 sdkm env | source:

// crates/sdkcore/src/shell/hook.rs
#[test]
fn fish_hook_uses_pipe_source_not_eval() {
    let script = generate_hook_script(Shell::Fish);
    assert!(script.contains("| source"), "fish hook 必须用 | source");
    assert!(!script.contains("eval ("), "fish hook 禁用 eval ( 消费多行脚本(会被压平)");
}

PowerShell 5.1 下 hook 脚本强制纯 ASCII。中文 Windows 的 PS 5.1 按 ANSI 代码页(GBK)解码原生命令输出,脚本里任何一个非 ASCII 字节(哪怕一个中文注释)都可能被误解码成破坏语法的字符。所以四套 hook 模板有统一断言:script.is_ascii()——包括"看起来无害"的注释在内,一个汉字都不能有。

六、这条路的代价(诚实段)

路线 C 不是免费的,把账摊开:

  • 每次回车一次 fork。缓存把它压到毫秒级无感,但严格说这就是比路线 A 多出来的固有成本——路线 A 的函数调用接近零开销,代价换成了多 shell 维护地狱,这是取舍不是完胜;
  • hook 依赖提示符事件。CI、纯脚本、没有交互 prompt 的场景 hook 不会转。所以全局层不依赖 hook(switch 直接写注册表/.profile 持久化),项目层在脚本场景可以手动 eval "$(sdkm env)";
  • stdout 纪律的代价。sdkm env 的 stdout 只准有脚本(要被 eval),所有诊断走 stderr——你在终端直接跑它,看到的输出对人不友好,对 shell 才友好。这是故意的,源码注释原话:"此处报错会让未 init 的场每次开 shell 都被喷"。config 缺失时它宁可静默吐空脚本。

结尾

到这一篇,环境这一层闭环了:hook 解决"谁动手改环境",四层优先级解决"听谁的",known-set 解决"怎么还原",五重指纹解决"多快"。

但整套机制有个前提——你 pin 的那个版本,得先存在于本地。

sdkm 内置 41 个工具:Java/Node/Python/Go/Maven 各有专属接口(Adoptium、nodejs.org、python-build-standalone),fzf/lazygit/k9s/duckdb/traefik 清一色 GitHub Releases——版本列表格式、资产命名规则(windows 还是 win32?x86_64 还是 amd64?)、压缩包格式,家家不一样。手写 41 份配置是维护噩梦,我的办法是先造一个通用下载引擎。

下一篇拆它:为什么 41 个内置工具里,36 个只需要十行声明。

  • 上一篇:《我把 nvm、jenv、pyenv 全卸了:一个 Rust 单文件管完 40+ 开发工具》
  • 仓库:github.com/borenchan/s… (觉得机制有意思就点个 star,文档全中文,踩坑 issues 见)