CC Switch 的升级:退出码 0 不等于升级成功
在自己的工具里包一层「一键升级」,最省事的写法是:调子进程跑上游的升级命令,等它返回,退出码是 0 就报成功。这个写法在大多数时候都对,但它会在一类场景里稳定地骗人——上游的 updater 明明什么都没装上,还是返回了 0。
CC Switch 的「关于」面板兼职做被管理 CLI 的版本探测、安装与升级,它没有采信退出码。src/components/settings/AboutSection.tsx 里那段执行循环 executeRun(:577-747)在命令返回之后,还要再读一次版本,用读到的结果决定这次到底算不算成功。这篇就拆这一段。
本文的依据
本文只静态读了 cc-switch 仓库的源码与 docs/ 下的用户手册,对应快照 3217f725(仓库内版本号 3.20.1),核对日 2026-08-31。我们没有安装、没有编译、也没有运行过这个桌面应用,所以下面提到的每一条都是代码里写着的分支,不是「跑起来会怎样」的结论。凡涉及界面呈现的部分,本文只说代码调用了什么,不描述看起来是什么样。
先说为什么是逐工具串行
executeRun 拿到的是一个工具名数组,可能来自单个工具的按钮,也可能来自批量入口。它没有把数组整个丢给后端,而是 for 循环一个一个来。代码注释(AboutSection.tsx:584-586)把理由写得很直白:后端会把整批拼成单个脚本再加 set -e,一旦第一个失败,整批在那里就中止了。
拆成串行之后,每个工具「独立成败、独立刷新版本」。这一点是后面所有判定能成立的前提——如果版本刷新是批量做的,就没办法把「这一个工具升级前后的版本」这组读数对上号。
升级前后各读一次版本
循环体每进一个工具,先从当前已有的版本数据里把这个工具升级前的版本和升级前拿到的最新版本记下来,然后才发起升级动作。命令返回之后,立刻只刷新这一个工具的版本。
判定就发生在这里(AboutSection.tsx:616-633)。当刷新后能读到版本号时,代码会再算一个条件:这次动作是 update、升级前有版本、刷新后的版本和升级前一模一样、而且拿刷新后的版本与最新版本比对下来仍然处于可升级的状态。四个条件同时成立,这次就不计入成功,而是被推进 failures 数组,kind 标成 versionUnchanged。
原注释把动机写清楚了:有些上游 updater 在没有实际改动版本的情况下仍然返回 0,所以要用刷新后的当前版本加上 latest_version 再确认一次,避免误报升级成功。
值得留意的是第四个条件——它不是只看「版本没变」。如果刷新后的版本已经等于最新版本,那「版本没变」是正常的(本来就已经是最新),这时不会被判成失败。真正被拦下来的是「版本没变,而且按已知的最新版本看它还该往上走」这一种。
第二种「退出码 0 却不算成功」
还有一条分支在 :643-656:命令退出码是 0,刷新之后却根本探不到版本。代码把它归成另一种软失败,kind 是 notRunnable,注释里给的例子是 openclaw 要求更高的 Node 版本——包装上了,但可执行文件跑不起来,--version 自然探不出东西。
这一类的展示文案优先取后端回传的 error 文本,取不到才回落到一条通用文案。相关的类型定义在同文件 :50-60:后端返回的 ToolVersion 里除了 version / latest_version / error,还有一个独立的布尔字段 installed_but_broken,:55-56 的注释特别要求前端直接读这个字段,不要靠匹配 error 的文案反推。这是同一个思路在字段设计层面的延续:状态要有显式的载体,不要让上层去猜字符串。
软失败与硬失败分开记
failures 数组里每一条都带一个 soft 布尔(:588-593)。命令自己抛错、走进 catch 的,soft 是 false;上面那两种「命令跑完了但结果需要人介入」的,soft 是 true。
分档发生在循环结束之后(:682-738):
| 情况 | 代码怎么报 |
|---|---|
| 一条失败都没有 | toast.success,带成功计数 |
| 成功数为 0,且没有硬失败 | toast.warning,标题按是否全是 versionUnchanged 二选一 |
| 成功数为 0,有硬失败 | toast.error |
| 部分成功 | toast.warning,标题里同时给成功数与失败数 |
这张表的读法是:同样都是「没升上去」,toast.error 这一档只留给命令本身报错。 版本没变、装上跑不起来,这两种上游命令其实是正常返回的,走 error 那一档既不准确,也会让人往「命令挂了」的方向排查。同时它们也绝不能被吞掉当成功——所以专门留了 warning 这一档。
细节还有一处:批量场景下每条失败只摘错误输出的最后一行,单工具场景才给完整详情(:694-704)。多个工具一起失败时,把每个的完整栈都堆进同一条提示里没法读。
二次判定的输入必须是新的
二次判定成立的隐含前提是:升级后那次读数不能是缓存。
这个文件里确实有缓存。AboutSection.tsx:195-204 的注释解释了动机:Tabs 组件切走会卸载非激活的面板,每次切回来都会重挂,全量重查等于给每个受管工具各起一次 --version 子进程再加一次网络请求。于是版本数据被存进了模块级变量 toolVersionsCache,TTL 是 TOOL_VERSIONS_CACHE_TTL_MS = 10 * 60 * 1000(:201,10 分钟)——这是 v3.20.1 里写死的默认配置,可以随版本改。缓存的生命周期等于这一次应用会话,:227-239 的惰性初始化甚至会「有缓存哪怕已过期也先拿旧值顶上」。
对升级判定来说,这套缓存要能被绕开才行。:340-353 的加载函数带一个 force 参数,置位时直接跳过 TTL 判断。另一处配套设计在 :319-322:单个工具的刷新只更新数据、不重置缓存时间戳,真实时间戳只由全量加载在 finally 里盖上(:366-370);缓存为空时以 at=0 起步,当作「尚未完成过全量加载」的过期哨兵。
也就是说,升级后那次单工具刷新拿到的是新读数,但它不会伪装成一次完整的全量刷新去延长整个缓存的寿命。这个区分很容易在实现时被合并掉,合并之后判定仍然是对的,缓存的新鲜度语义却会被污染。
判成「版本没变」之后,代码还多做了一步
两种软失败,代码都紧跟着调了一次 diagnoseToolSilently(:526-545),静默诊断这个工具是不是有多处安装。注释给的理由是:版本没变多半是因为被另一处安装遮蔽了;就算版本变了,另一处也可能仍然在。这个静默诊断在没有冲突时会主动清掉之前残留的冲突展示,因为用户可能已经在外部把重复安装卸掉了。
配套的还有升级前那一道::792-805 在真正执行前先跑 probeToolInstallations,探测失败不阻断升级,直接退回执行;只有被判为 needs_confirmation 的工具才走确认对话框。ToolUpgradeConfirmDialog.tsx:23-26 的组件注释写明触发条件是某个工具检测到两处及以上安装,对话框里会展示命令行实际命中的是哪一处(标了默认的那处就是升级目标),以及锚定之后将要执行的命令——:80-86 是把 plan.command 原样显示出来的。
把这三段连起来看,「退出码 0 但版本没变」在这个仓库里不是一句孤立的提示,它前面有一道确认、后面有一次诊断,指向的是同一个高频原因:升级动作打在了另一处安装上。
三个并发窗口
handleRunToolAction(:757-819)是安装与升级的统一入口锁。:749-756 的注释把它要堵的窗口逐条列了出来:一是 update 走 probeToolInstallations 的那段跨进程探测时间,二是 executeRun 内部状态落到 React commit 之前的几个 microtask,三是 install 直接进 executeRun 的同一段窗口。第一个窗口有多长,注释自己给的量级是 1 到 3 秒——这是注释里的说法,不是我们量出来的数,探测要起子进程还要看外部环境,实际多长不该照这个数去推。在飞的工具登记在一个叫 preflightTools 的 Set 里,:846-849 把它和其它状态合成 isAnyBusy,用来禁用动作按钮。
注释还写了批量场景的规则:只要有一个工具被锁住,整批就不开新一轮。理由回到最前面那条——后端 set -e 串行的语义假设是「一次性单脚本」,跨两次 IPC 并发会破坏它。
Windows 侧的命令是另一套
升级/安装用到的命令文本在 :110-165,是两套常量:POSIX 一套、Windows 一套,:163-165 用 isWindows() 选。差异不只是路径分隔符——POSIX 版对 Claude Code 与 OpenCode 用的是「官方脚本 || npm 全局安装」的双保险形式(:130、:138),Hermes 在 Windows 侧走的是 PowerShell 的 -EncodedCommand,文件里 :116-127 自己实现了 UTF-16LE 加 base64 的编码。后端返回的 ToolVersion 里也带 env_type(取值含 windows、wsl 等)与 wsl_distro,说明 Windows 下还要区分是原生环境还是 WSL。
这些命令与包名会随版本变,看的时候以仓库里那两个常量为准,别照抄文章。
手册这一块和代码对不上
顺手记三处,位置都给出来,怎么解释留给读者:
- 手册
docs/user-manual/zh/1-getting-started/1.5-settings.md的「本地环境检查」表所列的工具,比AboutSection.tsx:62-71的TOOL_NAMES常量少两项。 - 同一节里的一键安装命令代码块,与
AboutSection.tsx:129-161内置的不是一套。其中 Hermes 一条,手册写的是python3 -m pip install --upgrade "hermes-agent[web]",代码走的是 GitHub 上的install.sh(POSIX)或 PowerShell-EncodedCommand(Windows)。 - 代码内部也有漂移:
:343-344和:547-548的注释里还在说要跳过多少个--version子进程、一次诊断多少个工具,那个数字停在了TOOL_NAMES扩充之前。
以我们实读的仓库状态为准。说到这里就停,不再往「为什么没同步」上延伸。
v3.19.2 到 v3.20.1 这块动了什么
在这个文件范围内,主线是把 Pi 这个受管 CLI 补齐进各张清单:TOOL_NAMES、显示名映射、AppId 映射、两套安装命令文本各加一条。另外一处与本文主题直接相关:v3.19.2 时确认对话框的待办状态 pendingUpgrade 里没有 fromBatchEntry 这个字段,v3.20.1 加上了,对应的是批量升级入口下进行中状态归属的问题。
判定逻辑本身——版本二次读取、软硬失败分类、toast 分档——两版之间没有结构性改动。
值得抄走的形状
把这一段代码抽干净,剩下的就三条:
- 退出码只是必要条件。 判定成功要落到一次可观测状态的重新读取上,而且这次读取必须绕开缓存。
- 失败要分类。 「命令挂了」和「命令没挂但结果不对」是两种事,混在一起报,用的人排查方向就错了。
- 对判定结果做后续动作。 判成「版本没变」之后立刻去诊断多处安装,是把一次误报的可能性转成了一条可行动的线索。
这三条在 AboutSection.tsx 里分别对应 :616-633、:588-593 加 :682-738、:526-545。想自己核一遍的话,从 executeRun 那个 for 循环往下读就行。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、
路由指南与发布说明,以及 src/、src-tauri/、tests/ 的源码整理,
核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。
本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,
因此不涉及界面外观、操作手感与切换速度的任何描述。
文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。
该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
留言讨论
评论发布后会被人工复核,违规内容将被删除。
如果发表没有反应,可以前往联系我们告诉我们。