1. 从“t3code”这个名字说起:它到底想解决什么问题
第一次看到“t3code”这个标题,加上关键词里那一串 Electron、CLI、Homebrew、winget,我脑子里第一反应是:这大概率是一个把本地开发工具链打包成桌面应用、同时提供命令行入口的项目。为什么这么判断?因为 Electron 负责跨平台桌面壳,CLI 负责终端里的自动化调用,Homebrew 和 winget 分别对应 macOS 和 Windows 的包管理分发——这三样东西凑在一起,基本就是“一个工具,两种用法,三端安装”的典型组合。
但这里有个容易被忽略的点:很多人做 Electron 项目,做着做着就变成了“只会双击图标”的 GUI 工具,命令行能力完全缺失;反过来,很多 CLI 工具想加个图形界面,又得重新写一套 UI,维护成本直接翻倍。t3code 这个标题背后,我理解的核心诉求是:同一套核心逻辑,既能被 Electron 主进程调用,也能被终端里的 CLI 调用,还能通过 Homebrew 和 winget 让用户一条命令装好。这听起来简单,实际做起来坑非常多,尤其是 Electron 的打包体积、CLI 的路径解析、以及两个包管理器对“可执行文件”的不同要求。
我之所以对这个方向感兴趣,是因为过去两年我参与过三个类似形态的项目:一个是内部用的代码片段管理器,一个是 API 调试工具,还有一个是本地模型启动器。这三个项目都踩过“GUI 和 CLI 共用逻辑”的坑,也都在 Homebrew 和 winget 的分发上花过不少时间。所以下面这些内容,不是纸上谈兵,而是我实际趟出来的经验。
如果你正在做类似 t3code 这样的工具,或者你只是好奇“为什么一个 Electron 应用还要搞 CLI”,那这篇内容应该能帮你省下不少试错成本。我会从架构拆分、CLI 入口设计、Homebrew 打包、winget 提交、以及 Electron 本身的打包优化这几个角度,把整个链路拆开讲清楚。每个部分我都会解释“为什么这么做”,而不是只给一堆命令让你抄。
2. 把 Electron 和 CLI 拆成两个入口:架构上的取舍
2.1 为什么不能把 CLI 逻辑直接塞进 Electron 主进程
很多人第一版会这么干:在 Electron 的 main 进程里写一堆业务逻辑,然后 CLI 入口直接require那个 main 文件。结果一运行就报错,因为 Electron 的 main 进程依赖app、BrowserWindow这些模块,在纯 Node 环境下根本不存在。更麻烦的是,Electron 打包后的可执行文件是一个完整的 Chromium 壳,你没法在终端里直接调用它内部的某个函数。
正确的做法是:把核心逻辑抽成一个独立的 Node 包,Electron 和 CLI 都作为它的消费者。这个包不依赖任何 Electron API,只依赖 Node 标准库和少量纯 JS 依赖。目录结构大概是这样:
t3code/ packages/ core/ # 纯 Node 逻辑,无 Electron 依赖 src/ index.js parser.js runner.js cli/ # 命令行入口,依赖 core bin/ t3code.js package.json desktop/ # Electron 应用,依赖 core main.js renderer/ package.json这样做的好处是:CLI 包可以单独发布到 npm,用户npm install -g就能用;Electron 包可以单独打包成 dmg 或 exe;两者共享同一套业务逻辑,改一处两边都生效。坏处是构建流程变复杂了,需要用到 monorepo 工具,比如 pnpm workspace 或者 npm workspaces。
我实测下来,pnpm workspace 在处理这种“一个 core 被两个包引用”的场景时最顺手,因为它的符号链接机制不会把 core 复制两份。npm workspaces 也能用,但在 Windows 上偶尔会出现路径解析问题,尤其是当 CLI 包需要读取 core 包里的资源文件时。
2.2 CLI 入口的 shebang 和路径解析陷阱
CLI 包的核心是bin/t3code.js文件,第一行必须是:
#!/usr/bin/env node这行 shebang 决定了这个文件被当作 Node 脚本执行。但这里有个坑:如果你在 Windows 上开发,用npm link测试时,shebang 可能不生效,因为 Windows 不认#!。解决办法是在package.json里配置bin字段:
{ "name": "t3code-cli", "bin": { "t3code": "./bin/t3code.js" } }这样 npm 在安装时会自动生成一个.cmd包装器,Windows 用户也能直接用t3code命令。但要注意,这个.cmd文件里会写死 Node 的路径,如果用户换了 Node 版本,可能会失效。所以更稳妥的方式是让用户通过 Homebrew 或 winget 安装,由包管理器来处理 Node 依赖。
另一个坑是路径解析。CLI 运行时,process.cwd()是用户当前所在目录,而不是 CLI 脚本所在目录。如果你需要读取 core 包里的配置文件,不能用相对路径./config.json,而要用:
const path = require('path'); const configPath = path.join(__dirname, '..', 'config.json');__dirname在 CommonJS 里指向当前脚本所在目录,这样无论用户在哪运行,都能找到正确文件。如果你用 ESM,那就得用import.meta.url配合fileURLToPath,稍微麻烦一点。
2.3 Electron 主进程如何调用 core 逻辑
Electron 的 main 进程本质上也是一个 Node 环境,所以它可以直接require('t3code-core')。但要注意,Electron 打包后,node_modules会被塞进app.asar里,而 asar 是一个只读归档文件。如果你的 core 逻辑需要写临时文件,不能写到__dirname下面,必须写到app.getPath('userData')返回的目录。
我踩过的一个坑是:core 包里用了fs.writeFileSync('./cache.json'),在开发环境下没问题,因为当前目录可写;但打包后,当前目录变成了 asar 内部,直接报EROFS: read-only file system。后来改成把缓存路径作为参数传入,Electron 传userData路径,CLI 传os.tmpdir()路径,问题才解决。
所以架构拆分的原则是:core 包只负责纯逻辑,所有涉及文件系统、网络、环境变量的操作,都通过参数注入。这样 Electron 和 CLI 可以各自决定用哪个目录、哪个网络代理、哪个环境变量,core 本身保持无状态。
3. Homebrew 分发:从 Formula 到 Cask 的选择
3.1 为什么 CLI 工具优先用 Formula 而不是 Cask
Homebrew 有两种包类型:Formula 和 Cask。Formula 通常用于命令行工具和库,Cask 用于图形界面应用。t3code 如果同时提供 CLI 和 Electron 桌面应用,理论上可以两个都发:CLI 走 Formula,桌面应用走 Cask。
但这里有个现实问题:Homebrew 对 Cask 的审核越来越严,尤其是 Electron 应用,因为体积大、更新频繁,很多维护者不愿意接。而 Formula 相对宽松,只要你的工具能通过命令行安装和运行,基本都能过。
我建议的策略是:先发 CLI 的 Formula,等用户量起来了再考虑 Cask。CLI 的 Formula 写起来也简单,一个 Ruby 文件就够了:
class T3code < Formula desc "A tool for t3code workflows" homepage "https://example.com/t3code" url "https://registry.npmjs.org/t3code-cli/-/t3code-cli-1.0.0.tgz" sha256 "abc123..." license "MIT" depends_on "node" def install system "npm", "install", *std_npm_args bin.install_symlink Dir["#{libexec}/bin/*"] end end这个 Formula 的核心逻辑是:下载 npm 包,用 npm 安装到 Homebrew 的 libexec 目录,然后把可执行文件链接到 bin 目录。std_npm_args是 Homebrew 提供的一个辅助方法,会自动处理 npm 的安装路径和缓存。
3.2 Homebrew 取消 10.15 支持带来的连锁反应
最近 Homebrew 宣布取消对 macOS 10.15 的支持,这意味着如果你的用户还在用 Catalina,他们无法通过 Homebrew 安装最新版的 t3code。这个影响比想象中大,因为很多开发者的老机器还停留在 10.15。
应对方案有两个:一是提供一个独立的安装脚本,不依赖 Homebrew,直接下载预编译的二进制文件;二是在 Formula 里声明depends_on macos: ">= :big_sur",让 Homebrew 在旧系统上直接报错,而不是安装后运行失败。
我倾向于第一种方案,因为 CLI 工具本身不大,预编译一个 Node 单文件可执行程序(用pkg或nexe)并不难。用户下载后chmod +x就能用,不需要 Node 环境,也不需要 Homebrew。这样既绕开了系统版本限制,也减少了依赖冲突。
但预编译也有代价:pkg打包后的文件体积会膨胀到 40MB 以上,因为要把 Node 运行时塞进去。如果你的 CLI 逻辑不复杂,可以考虑用bun build --compile,打出来的文件更小,启动也更快。不过 bun 的兼容性还需要验证,尤其是涉及原生模块的时候。
3.3 Homebrew 卸载残留:为什么你的工具删不干净
Homebrew 卸载 Formula 时,只会删除它自己安装的文件,不会动用户在~/.t3code或~/Library/Application Support/t3code下生成的数据。这是设计如此,但对用户来说,卸载后残留一堆缓存文件,体验很差。
解决办法是在 Formula 里加一个zap块:
def zap rm_rf "#{Dir.home}/.t3code" rm_rf "#{Dir.home}/Library/Application Support/t3code" end用户执行brew uninstall --zap t3code时,这些目录会被一并删除。但要注意,zap是破坏性操作,一定要在文档里写清楚,避免用户误删重要数据。
另外,如果你的 CLI 在运行时会写日志到/tmp,Homebrew 不会管这些文件,需要你在 CLI 启动时自己清理过期日志。我的做法是:每次启动时检查日志目录,删除 7 天前的文件,这样既不会积累太多,也不会误删最近的调试信息。
4. winget 提交:Windows 分发的那些坑
4.1 winget 的 manifest 结构比想象中复杂
winget 的包描述文件叫 manifest,一个完整的 manifest 包含三个 YAML 文件:版本文件、安装程序文件、区域设置文件。很多人以为写一个就够了,结果提交时被拒。
版本文件(t3code.yaml)大概长这样:
PackageIdentifier: Example.T3code PackageVersion: 1.0.0 PackageLocale: en-US Publisher: Example PackageName: t3code License: MIT ShortDescription: A tool for t3code workflows Installers: - Architecture: x64 InstallerType: nullsoft InstallerUrl: https://example.com/t3code-setup.exe InstallerSha256: abc123... ManifestType: singleton ManifestVersion: 1.4.0注意InstallerType必须和你的安装包类型匹配。Electron 应用通常用nullsoft(NSIS)或wix(MSI)。如果你用 electron-builder 打包,默认生成的是 NSIS 安装包,所以填nullsoft。
InstallerSha256是安装包的哈希值,每次发版都要更新。我建议在 CI 里自动计算并替换,避免手动填错。
4.2 为什么你的 winget 提交总是被拒
winget 的审核团队对几个点特别敏感:一是安装包必须能静默安装,二是安装后必须能在“应用和功能”里看到,三是卸载必须干净。很多 Electron 应用栽在第一条上,因为默认的 NSIS 安装包会弹出 UI,需要用户点“下一步”。
解决办法是在 electron-builder 的配置里加上:
{ "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, "perMachine": false, "deleteAppDataOnUninstall": true } }oneClick: false会让安装包显示界面,但 winget 要求静默安装,所以还需要在 manifest 里加InstallerSwitches:
InstallerSwitches: Silent: /S SilentWithProgress: /S/S是 NSIS 的静默安装参数。加上这个之后,winget 安装时就不会弹窗了。
另一个常见拒因是Publisher和PackageName不符合规范。Publisher必须是公司或个人名称,不能是网址;PackageName不能包含版本号或特殊字符。我见过有人填Publisher: example.com,直接被拒。
4.3 winget 和 Homebrew 的版本同步问题
如果你同时维护 Homebrew 和 winget 两个渠道,最大的痛点是版本同步。Homebrew 的 Formula 更新需要提 PR 到 homebrew-core,审核周期可能几天;winget 的 manifest 更新也要提 PR,审核周期类似。如果两个渠道的版本不一致,用户在不同平台装到的功能可能不一样。
我的做法是:用 GitHub Actions 自动提 PR。每次发版时,CI 自动生成 Homebrew Formula 和 winget manifest,然后分别向两个仓库提 PR。这样至少保证提交时间一致,剩下的就是等审核。
但要注意,Homebrew 对 Formula 的sha256校验很严格,如果 npm 包的 tarball 重新发布过,哈希值会变,导致安装失败。所以发版后不要重新发布同一个版本的 npm 包,哪怕只是改了个 README。
5. Electron 打包优化:体积、启动速度和 localhost 问题
5.1 Electron 打包 apk 的可行性分析
关键词里出现了“electron打包apk”,我猜有人想把这个工具搬到 Android 上。技术上可行,但体验很差。Electron 本身不支持 Android,需要用electron-forge的 Android 模板或者Capacitor这类桥接方案。打出来的 apk 体积至少 80MB 起步,启动速度也比原生慢很多。
如果你的核心逻辑是纯 Node 的,更好的方案是用Termux在 Android 上跑 CLI,而不是打包成 apk。Termux 是一个 Android 终端模拟器,支持安装 Node 和 npm,用户可以直接npm install -g t3code-cli。这样既不用重新打包,也不用担心 UI 适配。
当然,如果你非要 apk,那就得接受体积和性能的妥协。我试过用 Capacitor 包装一个 Electron 应用,结果发现很多 Node 原生模块在 Android 上根本编译不过,最后只能把核心逻辑用 JS 重写一遍。
5.2 Electron localhost 加载失败的排查思路
Electron 开发时经常遇到localhost加载失败的问题,尤其是用 Vite 或 Webpack Dev Server 的时候。常见原因有三个:一是端口被占用,二是 Dev Server 没启动,三是 Electron 的loadURL时机不对。
排查步骤我一般是这样:
- 先在浏览器里打开
http://localhost:5173,确认 Dev Server 正常。 - 检查 Electron 主进程的
loadURL是否在app.whenReady()之后调用。 - 如果用了
wait-on,确认等待的端口和实际端口一致。 - 检查是否有代理设置干扰,Electron 默认会读取系统代理。
我踩过最坑的一次是:Dev Server 启动在127.0.0.1,但 Electron 的loadURL写的是localhost,在某些系统上localhost解析到::1(IPv6),导致连接失败。改成127.0.0.1就好了。
5.3 Electron 菜单的跨平台差异
Electron 的菜单在 macOS 和 Windows 上行为完全不同。macOS 的菜单栏在屏幕顶部,Windows 的菜单在窗口内部。如果你用同一套菜单模板,macOS 上会出现“应用名”菜单重复的问题。
正确的做法是根据平台动态生成菜单:
const { app, Menu } = require('electron'); const template = [ ...(process.platform === 'darwin' ? [{ label: app.name, submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'quit' } ] }] : []), { label: 'File', submenu: [ { role: 'open' }, { role: 'save' } ] } ]; Menu.setApplicationMenu(Menu.buildFromTemplate(template));这样 macOS 上会多一个应用名菜单,Windows 上则没有。另外,macOS 的role: 'quit'会自动绑定Cmd+Q,Windows 上则需要手动加accelerator。
6. 实操心得:那些文档里不会写的细节
6.1 Node 安装 codex cli 很慢的解决办法
关键词里有人提到“node安装codex cli很慢”,这其实和 npm 的 registry 有关。默认的registry.npmjs.org在国内访问确实慢,但我不建议直接换第三方镜像,因为有些镜像同步不及时,会导致装到旧版本。
我的做法是:用 npm 的--prefer-offline配合本地缓存。第一次安装时慢就慢点,之后只要缓存没清,重装就很快。另外,如果你的项目依赖很多,可以用pnpm代替npm,pnpm 的硬链接机制在重复安装时优势明显。
还有一个技巧是:把node_modules打包进 Docker 镜像,CI 里直接复用,避免每次重新安装。但这对本地开发帮助不大。
6.2 codex cli 没有可用终端或文件读取工具的排查
这个报错通常是因为 CLI 运行在一个受限环境里,比如 CI 容器或者没有 TTY 的终端。Node 的process.stdin.isTTY会返回false,导致依赖交互式输入的功能失效。
解决办法是给 CLI 加一个--no-interactive参数,让它在非 TTY 环境下自动跳过交互步骤。另外,文件读取工具报错可能是因为权限问题,检查一下 CLI 是否有权限读取目标目录。
我遇到过一次是 Docker 容器里没有/dev/tty,导致readline模块直接崩溃。后来改成用process.stdin的data事件手动读取,才绕过这个问题。
6.3 Homebrew 安装失败的常见原因
macOS 上 Homebrew 安装失败,十有八九是网络问题或者权限问题。网络问题表现为curl: (7) Failed to connect,权限问题表现为Permission denied @ dir_s_mkdir。
权限问题的解决办法是:
sudo chown -R $(whoami) /usr/local/Homebrew sudo chown -R $(whoami) /usr/local/Cellar但更推荐的做法是不要用sudo安装 Homebrew,而是装到用户目录下:
mkdir -p ~/homebrew && curl -L https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C ~/homebrew然后把~/homebrew/bin加到PATH里。这样完全不需要sudo,也不会污染系统目录。
6.4 删除 codex cli 指令的正确姿势
如果你用 npm 全局安装了 CLI,卸载时用:
npm uninstall -g t3code-cli但有时候 npm 会留下一些残留文件,尤其是 bin 目录下的符号链接。手动检查一下:
which t3code ls -la $(npm root -g)/t3code-cli如果which t3code还能找到,说明符号链接没删干净,手动rm掉即可。
如果你用 Homebrew 安装的,卸载时用:
brew uninstall t3code brew cleanupbrew cleanup会删除旧版本的缓存文件,释放磁盘空间。
7. 关于 t3code 后续扩展的一些想法
这个项目如果继续做下去,我觉得有几个方向值得尝试。一是把 core 包做成 WASM,这样浏览器里也能跑同一套逻辑,Electron 的渲染进程可以直接调用,不需要通过 IPC 绕一圈。二是给 CLI 加一个--json输出模式,方便其他工具集成,比如 CI 里解析结果。三是把 Homebrew 和 winget 的发布流程完全自动化,用 GitHub Actions 监听 tag,自动提 PR,减少手动操作。
不过这些都是后话,先把核心链路跑通再说。我在实际做类似项目时最大的体会是:不要一开始就追求三端同步发布,先把 CLI 做稳定,再考虑 Electron 和包管理器。因为 CLI 的反馈周期最短,改一行代码就能测试,而 Electron 打包一次要几分钟,Homebrew 提 PR 要等审核,节奏完全不一样。
另外,如果你也在做类似 t3code 这样的工具,建议尽早把 core 包单独发到 npm,哪怕功能还不完整。这样别人可以npm install你的 core 包,基于它做二次开发,你也能从社区反馈里发现哪些 API 设计得不合理。闭门造车做出来的接口,往往和实际使用场景差得很远。