上篇拆下载引擎时埋了个尾巴:你自己接一个非内置工具,要写什么。这篇补上。拆着拆着挖出一个更带劲的结论:sdkm 里压根没有"内置"和"自定义"的楚河汉界------41 个工具里只有 5 个有专属代码,剩下 36 个,和你 add-sdk 注册的野工具走同一条流水线。代码基于 master(v0.5.4),仓库链接在文末,每段都能对着源码看。
先掀桌:bun 的"编制",其实是自定义
先说一个可能冒犯官方的事实。
sdkm 号称内置 41 个工具。但你在源码的 Sdk 枚举里翻,只能翻出 5 个名字:java、maven、node、python、go。bun 呢?pnpm 呢?k9s 呢?不在。
它们去哪了?看 crates/util/src/sdk.rs:
rust
pub enum Sdk {
Built(BuiltinSdk),
Custom(String),
}
/// builtin sdk(专属路径 SDK:Java 两步查询、Node v 前缀、Python 双源等)
/// 引擎型内置 SDK(bun/pnpm 等)不在枚举中,以 Sdk::Custom 形态由 builtin::SDK_SEEDS 种子驱动
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BuiltinSdk {
///java programming language development environment
Java,
/// java programming language package manager
Maven,
/// node programming language
Node,
/// python programming language
Python,
/// go programming language
Go,
}
翻译成人话:bun 在运行时的身份,就是 Sdk::Custom("bun")。 和你随手 add-sdk 进来的 mytool,在类型系统眼里是同一种生物。
凭啥只有那 5 个有专属身份?因为它们的版本发现确实各有各的怪:Java 要两步查询 Adoptium,Node 的版本号带 v 前缀要剥,Python 挂着双数据源------怪到值得单独写代码伺候。bun 们没怪癖吗?不是,是它们的怪癖已经被引擎整个吸收了,就是上篇拆的那套同义词表加资产选择器。
所以"内置"在 sdkm 里的真实含义是:官方提前把表填好了,装机自带。 没有特权 API,没有隐藏通道,连版本发现都是同一个函数分发的。
这张表落盘在 ~/.sdkm/config.toml,41 个工具一人一条记录,官方的和你的混在一起,长得一模一样。想给 bun 换个镜像源?sdkm config set sdk.bun.version_url <你的源>------表在你机器上,官方给的也只是初始值。升级 sdkm 出了新工具也不会突袭:ensure_builtin_sdks 会对比种子表,把缺的条目静默补进你的 config。这个"静默"是有讲究的------config 读取跑在 hook 高频链路上,任何多余的 stdout 都会被 shell 的 eval 当命令执行掉,v0.4.7 的 fish 修复就是这么来的。这个坑够单独写一篇,先按下不表。
你要填的表,十一个字段
回到正题:接一个新工具,到底要写什么。
假设有个叫 mytool 的工具,下载地址长 https://example.com/mytool/1.2.3/mytool-1.2.3-linux-x64.tar.gz 这样,解压后二进制在 bin/ 里。接入它只需要:
bash
sdkm config add-sdk mytool \
--download-url "https://example.com/mytool/{version}/mytool-{version}-{os}-{arch}.{ext}" \
--bin-dir bin
回车,注册完成。之后 sdkm install mytool 1.2.3、sdkm switch mytool 1.2.3、sdkm current mytool,和内置工具一视同仁。
这张表在源码里的定义,是 crates/sdkcore/src/config/mod.rs 的 SdkConfig:
rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SdkConfig {
//sdk unique name
pub name: String,
//版本发现主源 URL
#[serde(default)]
pub version_url: Option<String>,
//版本发现备源 URL(主源失败时回退)
#[serde(default)]
pub version_fallback_url: Option<String>,
//下载主源 URL 模板(可选:None = 本地 switch-only SDK,仅切版本不远程安装)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub download_url: Option<String>,
//下载备源 URL 模板(下载主源失败时回退)
#[serde(default)]
pub download_fallback_url: Option<String>,
//current active version
#[serde(default)]
pub current_version: Option<String>,
//二进制目录名,None 表示二进制在 SDK 根目录(如 Node.js)
#[serde(default)]
pub bin_dir: Option<String>,
//extra env vars
pub extra_vars: HashMap<String, String>,
//extra paths relative to sdk symlink dir
#[serde(default)]
pub extra_paths: Vec<String>,
//下载模板 {os} 占位符的命名风格(default/short/adoptium;None = default)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os_style: Option<String>,
//下载模板 {arch} 占位符的命名风格(default/adoptium/python/go;None = default)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arch_style: Option<String>,
}
数一下,11 个字段,除了 name 全是可选项------这张表的"必填项"只有工具名,其他全是"有则用之,无则等价"。
字段不逐个念了,挑三个有戏的说。
version_url 是版本发现源,告诉 sdkm 去哪查这个工具有哪些版本,返回的是 JSON,格式下面细说。不填也行,代价是模糊匹配和 list -r 没了,只能精确版本安装------Maven 内置就是没配 version_url 的活例子,精确版本照样装,只是没法模糊匹配。
os_style 和 arch_style 这对卧龙凤雏,管的是 {os}、{arch} 占位符的输出方言。add-sdk 命令行没带这俩参数,要用 sdkm config set sdk.mytool.arch_style python 补------别急,这有整个系统里我最喜欢的一个设计,单独开一节。
extra_vars 是环境变量注入表,JAVA_HOME 的魔法就藏在这,同样单独开一节。
占位符:一个模板的十块骨头
上面那条 download-url 里,{version}、{os}、{arch}、{ext} 就是占位符。整个系统一共 10 个,全在 crates/util/src/config_helper.rs:
rust
/// ------------ PLACEHOLDER start ------------------
pub const PLACEHOLDER_SDK_DIR: &str = "{sdk_dir}";
pub const PLACEHOLDER_SDKM_HOME_DIR: &str = "{sdkm_home}";
pub const PLACEHOLDER_SDKS_INSTALL_DIR: &str = "{sdks_install_dir}";
pub const PLACEHOLDER_OS: &str = "{os}";
pub const PLACEHOLDER_ARCH: &str = "{arch}";
pub const PLACEHOLDER_OS_EXT: &str = "{ext}";
pub const PLACEHOLDER_VERSION: &str = "{version}";
pub const PLACEHOLDER_FEATURE_VERSION: &str = "{feature_version}";
pub const PLACEHOLDER_RELEASE_TAG: &str = "{release_tag}";
pub const PLACEHOLDER_PLATFORM: &str = "{platform}";
10 个占位符分三档:
- 通用四件套:
{version}{os}{arch}{ext}。绝大多数下载页,用的就是这四个变量。 - 专用三件套:
{feature_version}(Java 大版本号)、{release_tag}(Python 构建日期标签)、{platform}(Rust 风格平台三元组,如 x86_64-pc-windows-msvc)。伺候特殊源的。 - 路径三件套:
{sdk_dir}{sdkm_home}{sdks_install_dir}。不出现在下载 URL 里,专门给环境变量值用。
渲染器 TemplateRenderer 是两层的:静态层把 os / arch / ext 三个值用 OnceLock 缓存在进程里------这仨一个进程跑到底就定死了,算一次够用;动态层在安装时才注入 version、release_tag 这些"现在才知道"的值。
这个分层的直接好处:渲染一个 URL 的成本约等于十次字符串 replace。对一个可能跑在 hook 高频链路上的工具来说,这种抠法不是强迫症,是生存智慧。
写错了?没等到 404 就被拦下了
占位符系统最妙的一处,是它的严格模式。
模板引擎一般有两种脾气。一种是没替换的变量原样发出去,然后你收获一个 404,对着 URL 猜哪个变量没吃上;另一种是发现模板里还有没解析的 {xxx},当场翻脸。
sdkm 选的后一种。render() 的实现就几行:
rust
/// 严格模式:有未解析的 {key} 报错
pub fn render(&self, template: &str) -> Result<String> {
let result = self.render_loose(template);
// 检查是否还有未替换的占位符
if let Some(start) = result.find('{') {
if result[start..].contains('}') {
bail!("unresolved variables in: \"{}\"", result);
}
}
Ok(result)
}
先宽松渲染一遍,再扫结果里有没有 {......} 的组合,有就直接 bail。
意味着什么?你把 {version} 手滑打成 {vertion},sdkm 不会安安静静地把 mytool-1.2.3-{vertion}.tar.gz 发给服务器换一个 404 回来,而是在下载开始之前把整条 URL 怼到你脸上:这里有个变量没解析。错误在配置层被拦截,而不是在网络层被发现。
fail fast 的教科书实现:错误离它的产生者越近,修复成本越低。一个 404 你要排查网络、代理、防火墙;一个 unresolved variables,你只需要检查拼写。
配置校验是同款思路。download_url 落盘前会过一道校验器:把所有 {xxx} 先替换成 dummy,再走一遍标准 URL 解析,格式不对当场打回------相当于先过安检再登机,而不是起飞了才发现带错行李。所以从 add-sdk 到 config set,两条路进 config 的模板都被这道门拦过。
{arch} 的艺名表(户口本续篇)
上篇说过「一个 CPU 的三个户口本」:同一个 x86_64,在有的下载源叫 x64,有的叫 amd64。sdkm 的解法是给 {os} 和 {arch} 各配几套方言,官方下载源说什么话,占位符就输出什么词:
rust
/// ARCH 名称映射风格
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ArchStyle {
/// x64 / arm64 / x86(通用、Node、config.toml 默认)
#[default]
Default,
/// x64 / aarch64 / x86(Java Adoptium API)
Adoptium,
/// amd64 / arm64 / win32(Python Windows embed 包)
Python,
/// amd64 / arm64 / 386(Go 官方下载命名)
Go,
}
同一个 aarch64 的 Mac,在 Node 官方源里叫 arm64,在 Adoptium 里叫 aarch64;同一颗 x86_64,通用风格叫 x64,Python 和 Go 的下载页叫 amd64;到了 Go 的 32 位老机器那里甚至叫 386。OsStyle 那边还有三套:windows/win、darwin/mac。
这个设计的意义在哪?在于你不用管。add-sdk 时看一眼目标工具的下载页 URL 长什么样,风格对上号,剩下的映射引擎替你记。跨平台同义词表这种东西,本来就不该由人脑来扛。
版本发现:引擎只认 JSON 的长相
version_url 返回的 JSON,sdkm 不要求特定格式,它按"长相"识别。核心在 crates/sdkcore/src/version/discovery.rs:
rust
/// 标准 A/B 通用解析:自动识别三种 JSON 版本格式
struct ConfigBasedDiscovery {
/// GH Releases 形状识别时的资产前缀门(主可执行文件名)
asset_prefix: String,
}
impl VersionDiscovery for ConfigBasedDiscovery {
fn parse_version_data(&self, body: &str) -> Result<Vec<VersionEntry>> {
// 依次尝试三种 JSON 形状:
// 1. 扁平字符串数组:["3.12.8", ...]
// 2. 对象数组含 version 字段:[{"version": "3.12.8"}, ...]
// 3. GitHub Releases 数组(标准 B:tag 剥前缀 + 平台资产直链,asset_prefix 做前缀门)
if let Ok(entries) = parse_flat_version_array(body) {
return Ok(entries);
}
if let Ok(entries) = parse_version_object_array(body) {
return Ok(entries);
}
if let Ok(entries) = parse_releases(body, &self.asset_prefix) {
return Ok(entries);
}
bail!(
"[Custom SDK version validate] failed to auto-parse version data from configured version_url. Supported formats: flat string array, objects with 'version' field, or GitHub Releases array"
);
}
}
三种形状挨个试:["1.0.0", "1.1.0"] 这种扁平数组是第一种;[{"version": "1.0.0"}] 这种对象数组是第二种;GitHub Releases API 的原始响应是第三种。
注意一个细节:识别的是 JSON 结构,不是 URL。源码注释原话------"GH 形状靠 JSON 结构识别而非 URL------本地镜像/代理/测试 server 同样生效"。你的 version_url 指向公司内网代理也行,指向你自己起的静态 server 也行,引擎不问出身,只看长相。对国内用户来说,"接自建镜像源"和"接官方源"是同一个操作,零特殊处理。
哦对了,如果 version_url 指向的就是 GitHub Releases API,那连下载模板都不用填------这就引出了标准 B。
标准 B:GitHub Releases 的免填通道
全世界 CLI 工具的发行,GitHub Releases 占了半壁江山。sdkm 给这类工具开了条直通道,官方文档叫"标准 B":
bash
sdkm config add-sdk <name> --version-url "https://api.github.com/repos/<owner>/<repo>/releases"
就这一行,完了。download-url 不填,引擎看 JSON 长得像 GH Releases,就自动接管剩下所有事:
- 从
tag_name剥前缀拿版本号,bun-v1.4.2、v2.9.6、0.12.11通吃 - 自动过滤 prerelease 和 draft
- 自动补
per_page=100------GH 默认一页 30 条,对发版跟闹钟似的工具不够翻 - 从 assets 里按"OS 词 + arch 词 + 压缩包后缀"挑出你当前平台的直链
- 选中的
browser_download_url直接作为下载地址,模板都省了
官方 41 个工具里,31 个是这条通道一行进来的------上篇数的"31 行登记表",就是它们。剩下 10 个完整声明的:5 个是走专属路径的五巨头,另 5 个(helm、terraform、vault、consul、packer)走组合模式------版本列表照样问 GH Releases,下载却落到官方 CDN 模板,因为它们的 GH release 页面要么没有二进制资产(helm 的 v4.x 只挂签名文件),要么官方 CDN 更稳。一行式长这样(crates/util/src/builtin/core.rs):
rust
// ── 引擎型(标准 B 直链)─────────────────────────────────────────
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"]);
一行一个。上篇说"加新工具的成本趋近于零",现在你知道这个零从哪来的了:官方接工具的动作,和用户接工具的动作,是同一个动作。
这通道还有个进阶玩法:版本列表和下载源可以不是一家。helm 就是这么干的------它的 GH release 从 v4.x 起只有签名文件没有二进制资产,所以版本发现走 GH API,下载落到官方 CDN 模板 https://get.helm.sh/helm-v{version}-{os}-{arch}.{ext},源码注释原话:"GH release v4.x 仅签名文件(无二进制资产)→ 引擎无直链 → 落到官方 CDN 模板"。对国内网络,再垫一层 download_fallback_url 镜像模板,比如官方文档里直接给了 ghfast.top 的写法。版本列表走 GH、下载走镜像,各走各的道,互不耽误。
JAVA_HOME:环境变量里的甩手掌柜
现在看那张表里最妙的字段:extra_vars。先看官方自己怎么用。JAVA 的种子(crates/util/src/builtin/core.rs):
rust
pub const JAVA: SdkSeed = SdkSeed {
name: "java",
variant: Some(BuiltinSdk::Java),
version_url: "https://api.adoptium.net/v3/info/available_releases",
version_fallback_url: None,
// {feature_version} = 大版本号(21/17/11);{os}/{arch} = Adoptium 映射
download_url: Some(
"https://api.adoptium.net/v3/binary/latest/{feature_version}/ga/{os}/{arch}/jdk/hotspot/normal/eclipse",
),
download_fallback_url: None,
bin_dir: Some("bin"),
os_style: OsStyle::Adoptium,
arch_style: ArchStyle::Adoptium,
primary_executables: &["java", "javac"],
asset_prefix: None,
extra_vars: &[("JAVA_HOME", "{sdk_dir}")],
extra_paths: &[],
};
倒数第二行:extra_vars: &[("JAVA_HOME", "{sdk_dir}")]。
{sdk_dir} 渲染后是当前激活版本目录(符号链接的落点)。这意味着:sdkm switch java 21 的瞬间,JAVA_HOME 自动指向 21 的安装目录;切到 17,它自己跟过去。你不用改任何东西,因为它的值不是写死的路径,是"跟着激活版本走的指针"。
环境变量里的甩手掌柜:你只管切版本,它自己会动。
自定义工具一样有这待遇,官方文档的 groovy 示例:
bash
sdkm config add-sdk groovy \
--version-url "https://example.com/groovy/versions.json" \
--download-url "https://example.com/groovy/{version}/apache-groovy-{version}-bin.{ext}" \
--bin-dir bin \
--extra-var GROOVY_HOME="{sdk_dir}"
GROOVY_HOME 从此和 JAVA_HOME 同款待遇。而且这套 export 是 hook 动态重建的(第一篇拆过:进项目自动 set,离开项目自动 unset)------环境变量的生杀大权不在 shell 配置文件里,在 sdkm 手里,版本一换全体刷新。
彩蛋:不给下载地址也能上户口
最后说个反直觉的玩法:add-sdk 时连 download-url 都不给。
bash
sdkm config add-sdk mylocal --bin-dir bin
这是干嘛的?托管"已经在本地"的工具。你自己下载解压的目录------比如公司内网才有的内部工具------手动放到 store/mylocal/1.0.0/、store/mylocal/2.0.0/,然后 sdkm switch mylocal 1.0.0 照常切换。sdkm 管符号链接、管 PATH、管环境变量,就是不负责下载。
官方叫法:本地 switch-only SDK。我管它叫二手车过户------车是你自己提的,sdkm 负责上牌、记档、过户。
有人会问:那我不小心对它执行 sdkm install mylocal 2.0.0,翻车吗?看 crates/sdkcore/src/install/mod.rs 的处理:
rust
let download_url = if let Some(direct) = resolved.download_url.clone() {
// 版本发现源直接给出下载直链(如部分 GitHub release)
direct
} else {
// 走 download_url 模板渲染;模板缺失 = 本地 switch-only SDK,无法远程安装
let template = sdk_conf.download_url.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"`{}` has no download_url configured and cannot be installed remotely. \
This is a local switch-only SDK --- place the version dir at `store/{}/{}` manually, \
or set a download_url via `sdkm config set sdk.{}.download_url <url>`.",
sdk_name,
sdk_name,
full_version,
sdk_name
)
})?;
build_download_url(sdk, sdk_conf, template, &resolved)?
};
报错信息把两条出路直接写脸上了:要么把版本目录手动放到 store/mylocal/2.0.0,要么 config set 补一个 download_url。不猜、不蒙、不发一个注定 404 的请求。
顺带一提,这段报错里的 {} 是 Rust 的格式化占位符,不是 sdkm 的。两套占位符系统在同一行代码里擦肩而过、互不打扰------写模板引擎的人对"占位符"这个词的理解,在这一刻完成了闭环。
哪天想给它转成正经远程安装,sdkm config set sdk.mylocal.download_url <url> 一条命令补上,无缝转正。反过来 sdkm config remove-sdk mylocal 删的只是 config 条目,store 里的文件一个不动------删户口,不抄家。
所以,"自定义"到底自定义了什么
回到开头那个问题:接一个非内置工具,要写什么?
现在答案很清楚了:一两条 URL。而这两条 URL 用的占位符、渲染器、版本发现器、安装管线,和官方 41 个工具的是同一套。
sdkm 里"内置"和"自定义"的全部区别,是表由谁填、何时填------官方出厂填好,或者你现在填。工具进了表,众生平等:一样享受模糊匹配、断点续传、镜像加速、hook 联动、多作用域切换。
这也是"41 个工具"这个数字背后的底气:官方每加一个,只多写一行种子;而你没等到的那个,自己 add-sdk 也就两分钟的事。
发这篇的时候 star 已经 132 颗(发布当天实抓)。对一个 4MB 的单文件二进制来说,这数字还行;对一篇拆"填表系统"的源码文来说,你看到这里的耐心更值钱。
sdkm(GitHub:borenchan/sdkmate)单文件 4MB,Windows 原生,内置 41 个工具,自定义接入只要一条 URL。文档全中文,add-sdk 参数表、占位符速查表都在仓库 docs/custom-sdk.md。觉得这套"众生平等"的设计有意思,去仓库点个 star 就是最好的源码评审。
下一篇拆回滚与安全设计:install 中断的残留怎么清、卸载怎么删得干净、config 写到一半断电怎么办。