Solo Workspace 在真实开发中遇到的 3 个教训。
第一篇讲了为什么做------被碎片化工具折磨够了。
第二篇讲了怎么做------Go + 插件架构 + 配置链。
这篇不讲理念了,讲真实 commit 里的坑。5 天,12 个 commit,其中 3 个是在修自己前一天写出来的 bug。
按时间线来吧。
一、Day 3 推翻了自己的设计
Day 1 写配置系统的时候,想法很简单:
arduino
// (Day 1 的心理活动,不是真实代码)
// 所有配置放 ~/.solo/config.yaml → 完了
因为工具是给自己用的嘛。自己用,一个固定目录就够了,不需要什么加载链、优先级、自定义路径。简洁、够用。
两天后来了两件事,把这条设计打翻了。
第一件事:朋友问了个问题
早期版本做出来后,我发给一个朋友试用。
他说:"我有两个项目,一个用生产服务器,一个用测试服务器,配置怎么分开?"
我沉默了大概 10 秒。因为答案很尴尬------分不开。 所有东西都在 ~/.solo/ 里,要么全有要么全无。
第二件事:我想让 sw 给 AI agent 用
我在把自己的工作流接入 AI agent。agent 需要能执行命令、读配置、管理环境变量。
但如果 agent 也能读到 ~/.solo/ 里的全部数据------包括加密 vault------那加密就形同虚设了。
我需要一个能力:agent 调用 sw 时,只能看到它被允许看到的那部分数据。
这两个需求指向同一个解决方案:多配置 + 数据跟随配置。
重构后的设计
javascript
-c <path> 指定 → 最高优先级(AI agent 专属配置)
~/.solo/config.yaml → 全局默认(人用)
.solo.yaml (当前目录) → 项目级配置
AI agent 调 sw 的时候传 -c /tmp/workspace/sw-config.yaml,它读到的只是这个局部配置,碰不到我个人的 vault 和 secrets。
然后密码和 token 这些敏感信息怎么处理?不放在 config.yaml 里,放在一个独立的加密文件中跟随配置目录走:
go
// fileutil.go
var SecretResolver func(name string) (string, error)
SecretResolver 是一个插拔函数------在 init() 里由 secret 插件注册。config 层不关心密码存在哪、怎么解密,它只管问。
EmailConfig 里有个 PasswordSecret 字段:
go
type EmailConfig struct {
// ...
Password string `yaml:"password,omitempty"`
PasswordSecret string `yaml:"password_secret,omitempty"` // vault key
}
SMTP 密码不写明文在 YAML 里,而是引用 vault 中的密钥名。解析时自动解析:
go
func resolveEmailPassword(email *EmailConfig) (string, error) {
if email.PasswordSecret != "" {
return SecretResolver(email.PasswordSecret)
}
return email.Password, nil
}
还顺手解决了一个问题
原来 ServerConfig 只定义了 host/user/port 三个字段。后来发现不同服务器要记录不同信息------有的需要 key_path,有的需要 jump_host,有的需要打标签。
当时想:"要不要再加 5 个字段?不然下次又要改结构体。"
想了想,加了一个 Extra:
go
type ServerConfig struct {
Host string `yaml:"host"`
User string `yaml:"user"`
Port int `yaml:"port"`
Extra map[string]interface{} `yaml:"-"` // 未知字段自动落在这
}
然后自定义了 UnmarshalYAML:识别出来的三个字段进结构体,剩下的全塞进 Extra。
效果:用户写 YAML 时随意加字段,都不会报错,都存在 Extra 里。
yaml
servers:
prod:
host: 192.168.1.1
user: root
port: 22
key_path: ~/.ssh/prod_key # 自动进 Extra
tag: production # 自动进 Extra
回头看:不是"今天觉得完美明天推翻",而是"自己给自己提了两个需求,一个方案解决了。" 给 AI agent 用不是后来才附加上去的场景,它从一开始就在驱动设计------只不过 Day 1 的我自己还没意识到。
二、一个 ANSI 颜色泄露 bug
某天 sw todo list 出来了一堆奇怪的输出。
done 这个绿色单词后面的整行文字,全部变成了绿色。pending 后面的整行变成了黄色。
排查流水账
第一反应:是不是 fatih/color 的锅?
看了下源码,这个库就是给字符串包一层 ANSI code,不会自己泄露。不是它的问题。
第二反应:是不是终端模拟器的 bug?
iTerm2 和 Terminal 都试了,同样的现象。不是。
第三反应:缩小现象范围。
发现只出现在"单元格内容被截断"的场景------也就是 truncateVisible() 函数被调用的时候。
第四反应:细看 truncateVisible。
go
func truncateVisible(s string, max int) string {
// 截断逻辑......没问题
// 但截断之后,ANSI 颜色代码被截不见了?
}
不对,逻辑是对的。ANSI 代码在截断前就被剥离和恢复了。
第五反应:看 Table() 渲染函数。
问题出来了:
csharp
func Table(header []string, rows [][]string) {
// 渲染每个单元格
out.WriteString(padRight(cell, colWidths[i]))
// ......没有颜色重置
}
TodoStatus("done") 返回的是 \033[32mdone\033[0m,完整、正确。但如果这个单元格刚好被 padRight 填充了一段时间后,下一个单元格的内容紧接着输出------如果上一个 单元格带了颜色,而 padRight 只填了空格(不包含颜色重置),颜色就"流"到下一个单元格了。
实际上是 truncateVisible 截断后,结尾的 \033[0m 也被截掉了一部分,导致颜色代码没有正确闭合。修复很简单:
csharp
// 在每个彩色单元格后加颜色重置,防止颜色泄露
if ansiRE.MatchString(cell) {
out.WriteString("\x1b[0m")
}
就这 3 行代码。
代价
这个 bug 从发现到定位,大概花了 40 分钟。最终改动:3 行。
在 VSCode 的终端里从来没看到过这个问题,因为我的终端够宽,不需要截断。但在 iTerm2 里调整了一下窗口大小就出现了。
教训:CLI 的表格渲染坑比想象的多。 ANSI 代码不是可见字符,但它们会偷偷改变后续输出的显示效果。在"自己的终端、自己的配置、自己的数据"下自测通过,不代表在别的环境下不出问题。
三、中文宽度与自适应表格
项目列表的表格排序看起来总是有些不对劲。
bash
ID │ Path │ Description
───┼───────────────────────────────────────┼─────────────
1 │ /Users/shenyb/work/source/solo-workspace │ Solo Workspace
2 │ /Users/shenyb/Project/中文项目 │ 中文项目
第 2 行的列宽偏移了一个字符的位置,不多不少,就是一个中文字符的宽度。
原因简单到令人沮丧:中文字符的显示宽度是 2,但 Go 的 len() 返回 1。 padRight 按字符数填充空格,中文字符出现后,列宽计算全错。
修复
引入 go-runewidth 库。核心变化就是 visibleLen() 替代 len():
go
func visibleLen(s string) int {
return runewidth.StringWidth(stripANSI(s))
}
stripANSI 要先于宽度计算------ANSI 代码也不贡献宽度,但 len() 会算它们。
自适应:终端缩窄了怎么办
这是另一个人写的场景:ssh 到服务器上,终端只有 80 列宽。
Solo Workspace 的所有表格输出都通过 Table() 函数,它有个自动适应机制:
- 按内容算每列自然宽度
- 单列上限 80 字符
- 总宽度超出终端宽度 → 比例压缩
- 用 ceil 除法
(w*avail+total-1)/total,防止短列压缩到 0 - 最小列宽 6 字符
css
if totalNeeded > termWidth {
available := termWidth - (numCols-1)*sepLen
total := 0
for _, w := range colWidths { total += w }
if total > 0 {
for i := range colWidths {
colWidths[i] = (colWidths[i]*available + total - 1) / total
if colWidths[i] < 6 {
colWidths[i] = 6
}
}
}
}
那个 ceil 除法(total - 1 的细节)是为了防止类似"标签"这种本来就短的列被压到 0------如果 "pending" 这个 7 字符的标签被直接压没了,那表格就不知道这一列是什么状态了。
中英文混合的实际效果
现在 describe 那列有中文也不怕了。truncateVisible 截断时按显示宽度算,而不是按字符个数截:
go
func truncateVisible(s string, max int) string {
// 保留前导 ANSI 代码(颜色不能被截没)
// 然后按 runewidth 一个一个截
// 最后一个字符放不下就补个省略号
}
教训:自己用的工具,"我看着没问题"是不够的。 项目路径里有中文、ssh 到服务器上终端只有 80 列、数据多了被截断------这些场景在"自测"时不会自然覆盖到,但用户一用就暴露了。
回头看那 12 个 commit
翻 git log 看了下分布:
- 3 个是推翻自己前一天的设计
- 2 个是修"写之前根本不知道存在"的 bug
- 4 个是加边角料功能(补文档、修 typo、加默认值)
- 剩下的就是在干正事
不是项目复杂,而是小项目里每个细节都必须自己处理。没有 PM 挡需求、没有 QA 报 bug、没有同事 review。每个坑都是自己踩进去再爬出来的。
但换个角度想,踩坑本身也是一种设计输入。 第 3 天的重构如果没有第 1 天的"写死"经验,也不会知道灵活加载链到底解决了什么问题。ANSI 泄漏的 bug 如果不修,就不会意识到 "颜色代码对宽度计算是透明的"这件事。
下篇预告
第四篇讲讲真实使用体验:自己写的工具,自己用了一个月,到底好不好?有没有哪些功能是写完了但一次都没用过的?哪里不爽?
有人来看再继续写。
GitHub: github.com/shenyb/solo...
下一篇:用自己写的工具管理自己的项目
这是 Solo Workspace 开发实录的第三篇。