上篇讲了 sdkm 怎么用 hook 做到"回车即生效",这篇拆它最脏的部分:下载引擎。41 个内置工具,31 个的"安装逻辑"就是一行登记------剩下的活全被引擎包了。代码在 master 分支,链接在文末,每段都可以对着仓库看。
每个工具的安装文档,都是一段独立的行为艺术
你肯定见过这种场面。
装 Node,官方推 nvm,curl | sh 一把梭------pipe to sh,赛博俄罗斯轮盘,赌的就是作者今天没喝多。
装 Python,pyenv 的 README 先让你装一堆编译依赖,缺一个就给你表演 configure 失败,报错信息像天书连载。
装 Maven,去官网找 tar.gz 链接,下下来解压、设环境变量、export PATH,设完发现 shell 不认,重启终端才生效。
装 k9s?去 GitHub Releases 页面,面对二十个压缩包现场做题:k9s_Darwin_x86_64.tar.gz、k9s_Linux_arm64.tar.gz、k9s_Windows_amd64.zip......你得先知道自己是 Darwin 还是 macOS,x86_64 还是 amd64,选错了解压出来一个不认识的可执行文件,删掉重来。
这还只是"装上"。版本管理器的存在意义是反复装、随便切------总不能每次升级都重演一遍上面的流程吧。
所以 sdkm 的思路是反过来的:不让每个工具写一遍安装逻辑,而是让所有工具共用一个安装引擎。工具本身只需要"登记",登记的内容是一行声明。
31 行登记表
打开 crates/util/src/builtin/core.rs,你会看到 bun、pnpm、deno、uv 的全部"安装代码":
rust
pub const BUN: SdkSeed = SdkSeed::gh("bun", "https://api.github.com/repos/oven-sh/bun/releases", &["bun"]);
pub const PNPM: SdkSeed = SdkSeed::gh("pnpm", "https://api.github.com/repos/pnpm/pnpm/releases", &["pnpm"]);
pub const DENO: SdkSeed = SdkSeed::gh("deno", "https://api.github.com/repos/denoland/deno/releases", &["deno"]);
pub const UV: SdkSeed = SdkSeed::gh("uv", "https://api.github.com/repos/astral-sh/uv/releases", &["uv", "uvx"]);
一行一个工具:名字、Releases 地址、主命令。没了。
sdkm 内置 41 个工具,其中 31 个长这样------四分之三的接入成本就是这一行。剩下 10 个要写完整声明:Java、Node、Python、Go 这些语言运行时,加上 maven、helm、terraform、vault、consul、packer 这几个有特供版本的。它们各有各的脾气:版本源不在 GitHub、下载链接要拼模板、装完还要设 JAVA_HOME 之类的环境变量。
我数这 41 个的时候还发现几个"特例中的特例":
rust
// 资产前缀 claude 与 sdk 名 claude-code 不同
pub const CLAUDE_CODE: SdkSeed = SdkSeed::gh(
"claude-code",
"https://api.github.com/repos/anthropics/claude-code/releases",
&["claude"],
)
.prefix("claude");
// zip 内 bin/gh.exe,顶层单目录提升后 bin/ 保留
pub const GH: SdkSeed = SdkSeed::gh("gh", "https://api.github.com/repos/cli/cli/releases", &["gh"]).bin("bin");
.prefix() 和 .bin() 是链式小补丁:前者处理"工具名和压缩包前缀不一致"(claude-code 的包叫 claude-xxx),后者处理"二进制藏在 bin 子目录"(gh 的包解开是 bin/gh.exe)。
一行登记,两个可选补丁。这基本就是往 sdkm 里加一个新工具的全部工作量。前提是引擎足够聪明,而引擎才是这篇的主角。
引擎第一关:从二十个压缩包里认出你那份
GitHub Releases 的资产命名,是一门民间野生命名学。没有强制规范,每家按自己的方言来:
- bun 的小写连字符:
bun-windows-x64.zip - k9s 的首字母大写:
k9s_Darwin_x86_64.tar.gz - Go 官方的完整三元组:
go1.24.0.darwin-amd64.tar.gz - 还有 cpython 这种:
cpython-3.12.1+20240107-x86_64-unknown-linux-gnu-install_only.tar.gz
同一个 x86 CPU,圈子里有三种叫法:x64、x86_64、amd64。AMD 拿到 64 位命名权的时候大概没想到,这名字能分裂成三个户口本。macOS 更惨,一家四个名字:darwin、macos、mac、osx。
sdkm 的做法(crates/sdkcore/src/version/asset_selector.rs,333 行纯函数,无 IO 无状态)是把行业方言收敛成两张同义词表:
rust
/// OS 同义词表 → 标准化 OS 名(windows/darwin/linux)
fn os_synonym(token: &str) -> Option<&'static str> {
let trimmed = token.trim_end_matches(|c: char| c.is_ascii_digit());
match trimmed {
"windows" | "win32" | "win" => Some("windows"),
"darwin" | "macos" | "mac" | "osx" => Some("darwin"),
"linux" => Some("linux"),
_ => None,
}
}
/// arch 同义词表 → 标准化 arch 名(x64/arm64/x86)
fn arch_synonym(token: &str) -> Option<&'static str> {
match token {
"x64" | "x86_64" | "amd64" => Some("x64"),
"arm64" | "aarch64" => Some("arm64"),
"x86" | "386" | "i686" | "i386" => Some("x86"),
_ => None,
}
}
选包流程是"两段门 + 打分":
- 后缀白名单 :只认
.zip/.tar.gz/.tgz。这一道门直接把.sha256、.msi、.deb、.dmg、.asc签名文件全拦在外面------你只想要个二进制,Releases 页面给你摆一桌满汉全席,白名单就是你的筷子。 - 前缀门:资产名剥掉工具前缀后,剩下每一段都必须能识别(OS/arch/libc/版本数字),认不出的段直接拒绝。这道门专治"一个 Release 里混着兄弟工具"------uv 的 release 里有 uvx,codex 的 release 里有 app-server,认不出的段直接 pass 掉,不会错拿。
- 多候选打分 :同平台多个包时,
baseline/profile/debug这类修饰变体降权,musl/gnu 与编译目标不匹配的降权,平分取先出现。
bun 是最好的测试素材,它一个 release 能给你端出三种体质:
rust
#[test]
fn bun_windows_prefers_plain_over_baseline() {
let assets = [
c("bun-windows-x64-baseline.zip"),
c("bun-windows-x64.zip"),
c("bun-windows-x64-profile.zip"),
];
let picked = pick_asset(&assets, "bun").unwrap();
assert_eq!(picked.name, "bun-windows-x64.zip");
}
baseline 版本为了兼容老 CPU 砍掉了 AVX2 指令集------除非你明确知道自己要什么,否则引擎默认给你普通的那个。这类测试在 asset_selector.rs 里有十几组,全是真实工具的真实命名。
引擎第二关:解压,以及拆套娃
压缩包到手,解压(crates/sdkcore/src/install/extractor.rs)。这里有个很现实的问题:zip 和 tar.gz 的解压体验完全不同。
zip 支持随机访问,能先算出总文件数,进度条可以老老实实一格一格走;tar.gz 是 gzip 流,不支持 Seek,没法预知总大小,进度条只能转圈(spinner)。代码里的注释写得很直白:
rust
/// - Windows / .zip → 使用 zip crate,逐文件进度
/// - .tar.gz → 使用 flate2 + tar crate,spinner 动画(gzip 流不支持 Seek,无法手动迭代文件)
解压完还有个经典套娃问题:相当一部分项目的压缩包里,二进制不在根目录,而是包在一个 工具名-版本号/ 的顶层文件夹里。你解压到一个目录,发现目录里还是个目录。
sdkm 的处理是"单顶层目录自动提升":解压完看一眼,如果只有一个顶层子目录,就把它的内容提上来。而且自动提两次 ------因为 cpython 这种选手是双层套娃(cpython-3.12.1+xxx-install_only/python/bin/...),提一层不够,得提到版本目录里直接就是 bin 才算完。
拆套娃这种事,人干一次烦一次,引擎干一万次都一样。
引擎第三关:下载本身
下载器(downloader.rs,141 行)有两个值得说的细节。
一是断点续传 。下载前先看目标文件在不在、多大,在的话带 Range: bytes=<have>- 头发请求:服务器回 206 就接着下,回 200(不支持 Range 或文件变了)就推倒重来。网不稳的大文件(说的就是你,JDK)不用从零开始赌命。
rust
/// 同版本 tmp_dir 隔离保证只续传同 URL,不会跨版本拼接损坏。
pub async fn download_with_progress(client: &Client, url: &str, dest_path: &Path, pb: &ProgressBar) -> Result<()> {
// 断点续传:检查已下载部分文件大小
let have: u64 = match tokio::fs::metadata(dest_path).await {
Ok(m) => m.len(),
Err(_) => 0,
};
// 已有部分则带 Range 请求续传
let mut req = client.get(url);
if have > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={}-", have));
}
二是token 不乱给。GitHub API 请求可以带个人 token 提高限额,但 sdkm 不把 token 塞进全局的 HTTP client------因为配置里可能开着下载镜像,全局注入等于把 token 发给第三方代理。注释原话:
rust
/// 注意:github_token 不在此全局注入(避免镜像开启时把 token 发给第三方代理),
/// 由各 GH API 请求点自行添加 Authorization header
token 只在请求 GitHub API 时带上,走镜像的下载流量不带。这种细节平时没人注意,出事的时候才值钱。
还有些零碎但真实的问题
版本列表拉取:GitHub API 默认每页只给 30 条,bun 这种发版跟闹钟似的工具很快翻页,所以凡是 GitHub Releases 源,sdkm 自动补 per_page=100。
版本模糊匹配:输 sdkm install node 22,解析器拉列表找最接近的版本,弹一次确认------Resolved '22' → '22.13.1'. Install this version? 这种格式。你按个 y 的事,但它不会自作主张装一个你没确认过的版本。
残留清理:上次装到一半 Ctrl+C 的临时文件,下次 install 同一个工具时先清干净,不会拿半截 zip 解出个薛定谔的二进制。
缓存:版本列表有 TTL 缓存,短时间反复查询不打 GitHub API 的脸(API 限额是稀缺资源,滥用会被限流教做人)。
为什么这么写
为什么引擎要抠到这个粒度?因为表里每一行都是装工具时真撞过的墙。
回到开头那 31 行。它们的成立,靠的是把"平台差异"这个最烦人的东西全部吸收进引擎:
- 41 套安装文档的碎片化 → 两张同义词表 + 一套打分规则
- 压缩包的千奇百怪 → 白名单 + 前缀门 + 自动拆套娃
- 网络的不靠谱 → 断点续传 + 缓存 + 备源 fallback
- 安全的暗坑 → token 精确注入 + 模糊匹配人工确认
引擎代码总共 1483 行(install/ 目录 934 行 + asset_selector.rs 333 行 + github_releases.rs 216 行),换来的是加新工具的成本降到一行。
这大概就是"通用"的真正含义:不是支持得多,而是支持下一个的成本趋近于零。
sdkm(GitHub:borenchan/sdkmate)单文件 4MB,Windows 原生,内置 41 个工具,也支持自定义 SDK 接入(同样几行配置)。文档全中文,命令能复制就能跑。觉得这套引擎有意思的话,去仓库点个 star 就是最好的源码评审。
下一篇拆自定义 SDK:你自己接一个非内置工具要写什么。