chore(npm): 去掉 Node fallback,安装不再编译原生模块 - #1047
Conversation
`npm i -g botmux` 之前会**从源码编译一次 node-pty**:主包 `dependencies` 挂着它,
而 node-pty **没有 linux 预编译**(`prebuilds/` 只有 darwin/win32),所以 Linux 上
必走 node-gyp。真正的代价不是那几秒,是**把一整条编译工具链变成安装前提**
(python3 / make / gcc)——这个前提在很多机器和精简镜像里根本不成立,那里不是慢,
是直接装不上。
而那份编译产物**并非无用**:它是 `bin: {botmux: "dist/cli.js"}` 这条 Node 降级路径
的真实依赖(`dist/adapters/backend/pty-backend.js` 确实 require 它)。所以不能只删
`dependencies` —— 那是最糟组合:fallback 仍然挂着,一旦走到就
`Cannot find module 'node-pty'`。本 commit 把这条路径**整条拆掉**:
1. `node-pty` + `@napi-rs/canvas` → `devDependencies`。**不是删除**:
`scripts/build-bun-binary.mjs:89` 要 `require.resolve('node-pty/package.json')`
取 `pty.node` 嵌进二进制,canvas 同理(卡片渲染)。它们从「用户装包时要编的运行
时依赖」变成「我们编二进制时要用的构建期依赖」。
2. **删 `bin`** —— 这是 fallback 的入口,留着等于没删。
3. postinstall `bail()`(warn + exit 0)→ `fail()`(exit 1)。没有兜底之后,
exit 0 而不写 launcher 等于给用户留下一个**不存在的 `botmux` 命令**,报错要等到很
久以后。三个 guard 分支(非全局装 / 源码 checkout 内)仍静默 exit 0 —— 那不是失
败,是「无事可做」。
4. **新增 musl 检测**。npm 按 `os`/`cpu` 选平台子包,而 glibc 与 musl 这两个字段
**完全相同**(`linux`/`x64`),所以 Alpine 上 npm 会照样装 glibc 包、运行时挂在
一个不说明原因的 loader 错误上——**装"成功"了但跑不了,比明确失败糟得多**。检测
优先用 `process.report.getReport().header.glibcVersionRuntime`(比翻文件系统权威),
回落 `/lib/ld-musl-*` 与 `/etc/alpine-release`,并且**只在正面观测到 musl 时才判
定**,避免 glibc 机器被假阳性拦住。
(npm 其实支持 `libc` 字段——`npm help package-json` 没写但线上在用,例如
`@napi-rs/canvas-linux-x64-musl` 就发着 `libc: musl`。等 botmux 也发 musl 子包之
后,npm 会自己选对,这个守卫就从「不支持 musl」降级成「musl 包没装上」的诊断。)
5. **三处会变成假话的提示全改了**,最要紧的是
`The botmux command still works via Node if this platform is unsupported`
—— 删掉 fallback 后这句是骗人。Windows 的正解是 **WSL2**(报告为 linux、完整支持),
不是 Node 安装。README 两处同步改(含「安装不编译任何原生模块」)。
验证:
- **端到端(隔离 HOME + prefix,未碰生产 `~/.botmux`)**:`npm pack` 出 9.1M 包后真
`npm i -g`,**全树零 `pty.node`(完全没编译)**、未误写 launcher,且失败信息准确:
`no prebuilt binary package for linux-x64` + WSL2 指引,`npm exit=1`
- **二进制仍能编出**(devDependencies 够用):`bun scripts/build-bun-binary.mjs` 成功,
`strings` 在产物里查到 4 处 `pty.node` ⟹ 真嵌进去了
- **musl 检测双向验证**:本机 glibc 2.36 → false;伪造 musl report → true;
report 缺失 → 回落文件系统探测(本机 → false)
- **反向变异 3 处全有牙**:把 `bin` 加回 → 红;`node-pty` 挪回 `dependencies` → 红;
`fail()` 改回 `exit 0` → 红
- 新增 2 条 source pin 防回退(`bin` 必须不存在;两个原生依赖必须不在
`dependencies` 而在 `devDependencies`)
- `bun run build` exit 0;`npm-binary-distribution` 16/16;相关 22 文件 / 199 测试全绿
后续(不在本 PR):加 musl 编译目标与 `botmux-linux-{x64,arm64}-musl` 子包,声明
`libc` 让 npm 自动选择;那时把第 4 条的报错改成「musl 包未安装」的诊断。
复审抓到一个 P0:**桌面发布链会被上一个 commit 炸掉,而 PR CI 的 6 项检查看不见它。**
`scripts/prepare-desktop-runtime.mjs:49` 给桌面 runtime 树跑的是
bun install --production --frozen-lockfile --ignore-scripts --os darwin --cpu '*'
`--production` **不装 devDependencies**。我上一个 commit 把 `node-pty` 与
`@napi-rs/canvas` 挪进 devDependencies,于是下一次 desktop 构建时 runtime 树里这两个
**双双消失**:canvas 会被 `assertBundledCanvasArchitectures()` 抛错拦住(响的),而
**node-pty 在那个脚本里没有任何断言**——真出问题是「桌面 app 里终端功能直接残废」,
且要等发版后用户才碰到。
而 `release.yml` 的 `desktop` job 跑在 macOS runner 上、**只在发版时触发**,不在 PR
CI 里。所以上一轮「6/6 SUCCESS」是真的,但它绿的是 CLI 那条链。
**实测确认三种依赖类型在 `--production` 下的取舍**(用同一份 flag 跑隔离目录):
| 位置 | `--production` 会装吗 |
|---|---|
| `dependencies` | ✅ |
| `devDependencies` | ❌ **跳过** |
| `optionalDependencies` | ✅ **会装** |
所以改挂 `optionalDependencies`:**同时满足两个方向**
- 桌面 runtime 树照样拿到它们(`--production` 装 optional);
- 终端用户装包时,optional 的构建失败是**可跳过**而非致命 —— 这正是「编译工具链不该
是安装前提」的语义。node-pty 没有 linux 预编译,挂 `dependencies` 才会把 node-gyp
变成硬前提。
顺带修掉复审指出的另两点:
**① `header.musl` 分支是死代码,注释也不准。** 在真 `node:22-alpine` 里实测:
`process.report.getReport().header` 有 23 个 key,**既没有 `glibcVersionRuntime`
也没有任何 musl key**。所以那份 report 只能当**负向信号**用(有 glibc 字段 ⟹ 一定是
glibc,直接 return false),Alpine 上真正做判定的是 `ld-musl-*` loader 探测。删掉
positive 分支,并把注释改成实测结论 + 「不要把 `header.musl` 加回来」。
**② `isMuslLinux` 之前零自动化测试**(我只手工验过)。假阳性会**拦住每一个正常
glibc 用户装包**,比漏判严重,而这个函数一旦被谁「顺手清理」掉 fs 探测,没有任何测试
会红。补 4 条:
- loader 存在 → 报 musl 专属原因(且必须是 musl 消息而非 generic「找不到子包」,因为
Alpine 上 glibc 子包**能** resolve,generic 那条永远不会触发)
- loader 不存在 → **不**声称 musl(假阳性方向,这条最重要)
- glibc 短路:本机 `glibcVersionRuntime` 有值 ⟹ 任何探测之前就settle
- source pin:两个信号都不许消失
测试驱动的是**真脚本**(复制进临时包再跑),只把它探测的目录重定向到可控的 fake root;
并把 report 读取替换成 `undefined` 来模拟「跑在 musl Node 上」——否则本机的 glibc 短路
会让 fixture 永远走不到探测分支,测试等于零覆盖。
⚠️ source pin 里比对的是**剥掉注释后的代码**:文件的 docblock 故意提到了被否掉的
`header.musl` 方案,直接 match 全文会让断言打在自己的解释文字上(我第一版就是这么红的)。
验证:
- **P0 已修(对真 manifest 复刻脚本的 flag)**:`bun install --production
--frozen-lockfile --ignore-scripts --os darwin --cpu '*'` → **node-pty 在、
canvas-darwin-arm64 与 canvas-darwin-x64 都在**,265 包 exit 0
(顺带确认 `--frozen-lockfile` 不受影响,这是候选方案 (b) 的已知风险点)
- **主目标未回退**:隔离 HOME+prefix 真 `npm i -g` → **全树零 `pty.node`**、未误写
launcher、失败信息准确
- **反向变异 2 处有牙**:删掉 `ld-musl-*` 探测 → **2 条红**;把 musl guard 挪到
subpackage 解析之后 → **1 条红**
- `npm-binary-distribution` **20/20**;分发 + desktop 全量 **20 文件 / 167 测试全绿**;
`bun run build` exit 0
复审确认的既有疑点(**非本 PR 引入**,不挡合):`maintenance.ts:318` 与
`managed-spawn.ts:42` 直接 `spawn(process.execPath, [botmuxCliEntry(), …])`,编译态下
`botmuxCliEntry()` 会解析成 `/dist/cli.js`(`packageRoot()` 从 `/$bunfs` 走到 `/`)。
本 PR 未动这两个文件,但它让 npm-binary 成为唯一安装路径,这条 restart 路径的暴露面
变大 —— 另开 ticket 验证。
|
先说结论:这个 PR 的方向我们认为是对的—— 不过我们在复审中发现同一个「最糟组合」在另一条路上又出现了一次,想请你在合入前看一眼。 🔴
|
| master | 本 PR | |
|---|---|---|
@napi-rs/canvas-darwin-arm64 |
PRESENT | MISSING |
@napi-rs/canvas-darwin-x64 |
PRESENT | MISSING |
node_modules/node-pty |
PRESENT | MISSING |
两条后果都实测复现了:
- 打包期:
prepare-desktop-runtime.mjs:70的assertBundledCanvasArchitectures()必然 throw(Bundled runtime is missing @napi-rs/canvas-darwin-arm64)→release.yml:79的bun run desktop:runtime失败 → 发版时 desktop job 挂。 - 运行期(假设绕过该断言):把 PR 的
dist放进上述 staged 树,node dist/cli.js --help→ exit 1,ERR_MODULE_NOT_FOUND: Cannot find package 'node-pty'(抛点dist/adapters/backend/tmux-backend.js)。连--help都活不下来。
⭐ 值得单独一提的是失败性质比 master 更宽:master 的 staged 树里带着 node-pty/prebuilds/darwin-{arm64,x64}/pty.node,而 desktop 只出 macOS,所以 master 在这条路上是好的;它的失败模式是 native 加载失败(平台相关)。本 PR 变成了解析层的 ERR_MODULE_NOT_FOUND(平台无关),因此 macOS 同样会死。
两个让它不容易被发现的因素
- PR CI 结构上到不了这里。desktop job 在
release.yml:15-17,只在push: tags: v*且github.actor == 'deepcoldy'、runs-on: macos-14时才跑;ci.yml只有build+bun-binary(都是 ubuntu)。所以目前 CI 6/6 全绿并不覆盖这条路径。 - 现有测试是零覆盖。
test/desktop/desktop-bundled-runtime.test.ts里其实写着runtime/node_modules/node-pty/build/Release/pty.node,但它只是把这个字符串喂给matchesArchGlob()做 glob 匹配(且existsSync: () => true是 mock),从不校验文件真实存在——我们在本 PR 上跑该文件是 4/4 全绿。
建议的修法
在 desktop staging 时把这两个包补回来(--production 之后单独补装,或该处不用 --production)。顺带一点:--cpu '*' 下 node-pty 会同时拉到 darwin-arm64/x64 两个变体,正好匹配 Universal app 的双 arch 需求。
另外建议把门禁做成有牙的:assertBundledCanvasArchitectures 扩成同时断言 node-pty/build/Release/pty.node 真实存在;如果愿意再加一条「staged 树跑 node dist/cli.js --help exit 0」的真实机制断言,就能把这次的问题固化住,而不必依赖只在 tag push 时才跑的 desktop job。
🟡 非阻断:musl 检测里有一行是死代码
scripts/postinstall-bin.mjs 的 isMuslLinux() 中:
if (header.musl || header.muslVersionRuntime) return true;我们在真 Alpine 容器里实测(node:22-alpine):header.musl 和 header.muslVersionRuntime 都是 undefined——Node 的 report header 只有 glibcVersionRuntime / glibcVersionCompiler 两个 libc 相关键。实际生效的是 /lib/ld-musl-* 那条回落。
功能本身是正确的,双向都验过了:真 Alpine → true;node:22-bookworm-slim(glibc 2.36)→ false,无假阳性。所以这只是注释把这行称作「最权威」略名不副实,删掉该行或调整注释即可,不影响合入。
我们已确认成立的部分
- 二进制仍编得出来,
devDependencies足够:bun scripts/build-bun-binary.mjs成功,pty.node确实从 devDeps resolve 到并嵌入了产物,产物能跑。 - 反向变异确实有牙:
node-pty挪回dependencies→ 红(expected '^1.1.0' to be undefined);fail()改回exit 0→ 红。 bun run buildexit 0;npm-binary-distribution16/16;相关 21 文件 / 175 测试全绿。install.sh(curl 那条)不受影响;install-diagnostics.ts的pnpm-global探测确实一行没动。- 我们也全仓扫过是否还有第三个只读
dependencies的消费方:--production全仓仅prepare-desktop-runtime.mjs一处,其余.dependencies命中都是无关项(workflow-core 发布包 smoke、插件 manifest),electron-builder.yml只是对同一棵 staged 树做 glob。没有第三处。
以上是自动评审的初步意见,两条结论均由两个 reviewer 在各自独立的环境中实测复现(含真 Alpine / glibc 容器对照)。可能仍有我们没覆盖到的前提——如果 desktop staging 那边其实另有补装步骤是我们漏看了,请直接指出。最终以维护者审阅为准。
复审抓到 R2 留下的 P1:**改成 `optionalDependencies` 之后,有编译工具链的机器上 `npm i -g botmux` 仍然会从源码编一遍 node-pty**,而 README 里「安装过程不编译任何原生 模块」这句是本 PR 加的,在那些机器上是假话。申晗定了「修到底」。 **我上一轮那句「全树零 pty.node」是个假阴性,先说清它错在哪**:我 pack 的是本地 `0.0.0` 包,没有对应平台子包 ⟹ postinstall **必然** exit 1 ⟹ **npm 在 postinstall 失败 时回滚整个 `node_modules`**。真实时序是「装 optional → 跑 node-pty 的 install script → node-gyp 编出 `build/Release/pty.node` → postinstall 失败 → npm 删掉整棵树」,我在**被 删空的树**上数 pty.node,当然是零。改成本地装(postinstall 走 guard 静默 exit 0、树保 留)立刻看到 `gyp http GET node-headers` 和 71,576 字节的产物。⚠️ **注定失败的 fixture 对成功场景零证据** —— 这与 x64ArchFiles 那次是同一类。 **修法**:node-pty **彻底退出用户安装链路**。 - `optionalDependencies` → `devDependencies`。optional 也不行:npm 照样会跑它的 `install` 脚本(`node scripts/prebuild.js || node-gyp rebuild`),而 node-pty **没有 linux 预编译**,必然落到 node-gyp。 - 桌面 runtime 树改由 `stageNodePty()` **复制**进去(`bun install --production` 拿不到 devDependencies)。可行的依据:node-pty 的 loader 顺序是 `build/Release → build/Debug → prebuilds/<platform>-<arch>`(lib/utils.js),而 npm 包 自带 darwin-arm64/x64 prebuilds,`--ignore-scripts` 下 `build/Release` 从不存在 ⟹ **macOS 从 prebuild 加载,桌面侧本来就不需要编译**。其唯一依赖 `node-addon-api` 是纯 编译期头文件,运行时不需要。 - `@napi-rs/canvas` **留在 optional**:它没有 install script(永不编译),且桌面树必须靠 `--cpu '*'` 拿双架构,那只对「`--production` 会装的依赖」生效。 **🔴 复制时必须排除 `build/`,这是正确性而非整洁问题。** 我自己的断言逮到了它:builder 是 Linux,其 `node_modules` 里的 `build/Release/pty.node` 是 **Linux ELF**(`file` 实 证),而 loader **先看 `build/Release`** ⟹ 照抄会让 macOS 优先加载一个 Linux 二进制、 原生模块直接加载失败。于是 `cp` 加 `filter`,并补第二条 fail-closed 断言:staged 树里 **不允许**出现 builder 的 `build/Release/pty.node`。 验证: - **零编译(这次落在成功路径上)**:本地装真 tarball,`npm exit=0`、**277 包保留**、 **全树零 `pty.node`**、node-pty 未安装。对照 R2 同一 harness 会打出 gyp 日志。 - **桌面侧(复刻脚本的 install + stage)**:darwin-arm64/x64 **prebuild 与 spawn-helper 四个都在**、canvas 双 arch 仍在、`build/` 整个目录没被复制,且**独立于我的 filter 代码**再查一次:staged node-pty 里 **ELF 二进制数 = 0**。 - `isUnderBuildDir` 边界实跑 7 例全对(含 `buildinfo.txt` 这种前缀相同的假阳性、 `lib/build.js` 这种名字含 build 的文件)。 - **反向变异**:删 `await stageNodePty();` → 红;去掉 `filter:` 行 → 红。⚠️ 后者第一版**没抓到** —— 我原来只断言 `isUnderBuildDir` 这个标识符存在,而删掉 `filter:` 那行标识符还在。改成正则匹配「filter 确实接进了 cp」才有牙。 - 二进制仍能编出(devDependencies 够用);`bun run build` exit 0; 分发 + desktop **20 文件 / 168 测试全绿**。 - README 那句补上「npm 只是把它放到位」,措辞与实际一致。
只改注释与测试注释,逻辑一字未动(filter + 两条 fail-closed 断言全部保留)。 上一个 commit 我把理由写成「builder 是 Linux,`build/Release/pty.node` 是 Linux ELF」。**这是拿我本机的观测当成了发布路径的事实**,查下来两处都不对: - `release.yml` 的 `desktop` job 跑在 **`macos-14`**,不是 Linux; - macOS 上**根本不会产生 `build/`**:node-pty 的 `scripts/prebuild.js` 在 `prebuilds/<platform>-<arch>` 存在时直接 `exit(0)`,只有缺失才落到 `node-gyp rebuild`。darwin 有预编译 ⟹ 不编译。 我看到的 ELF 是**这台 Linux 开发机**的产物(linux 无预编译 ⟹ 必编),它证明的是 「在 Linux 上手动跑 desktop:runtime 会污染」,不是「发版会出这个事」。 **但 filter 与断言仍然必须留**,理由换成站得住的那个:只要 builder 树上**存在任何 编译残留**,cp 就会带进目标树,而 loader 顺序让它压过正确的 prebuild。残留的现实来源 有三条——Linux 开发机、`npm_config_build_from_source=true`、以及未来 node-pty 停发 某个 darwin 预编译。⚠️ 且**最坏情况是静默且按 arch 分裂的**:macOS builder 编出的是**单 arch** Mach-O, 它会遮蔽 Universal 包里**另一个 arch** 的 prebuild ⟹ 一半机器上终端功能挂掉,而构建 期什么都不报。 ⭐记录这次错误的形状:结论(保留防御)恰好正确,但**理由错了同样危险**——它会让下一个 人按错误的模型判断「这个防御还需不需要」。
复审抓到两条,都在本 PR 范围内,且第一条是我**标题里承诺却没实现**的功能。 **🔴 P0:「npm 按 libc 自动选包」三处断链,一处都没接** 标题这么写,但 npm 那条路径我一行都没改,实测三处断: 1. `inject-optional-binaries.mjs` 的 `PLATFORM_PACKAGES` **硬编码 4 个** ⟹ 发布的主包 optionalDependencies 里**永远不会出现** musl 子包。而 `binary-subpackages` job 会把 6 个都发上 npm ⟹ **musl 包存在于 registry 上,却没有 任何东西引用它**,npm 连考虑的机会都没有。 2. `postinstall-bin.mjs` 的 `SUBPACKAGE` 无 libc 后缀 ⟹ 即使修了 1,Alpine 上 npm 装 的是 `-musl` 包,postinstall 却去找 glibc 名字,照样失败。 3. `isMuslLinux()` 那道 **hard fail 还活着** ⟹ Alpine 上 `npm i -g botmux` 会在解析子包 **之前**就被拦死,报错文案还是「cannot run on musl」——musl 二进制都发了,这句成了 假话。#1047 的注释自己预言过这天(「becomes a diagnostic」),预言兑现了,guard 没翻。 修法:`PLATFORM_PACKAGES` 加两个 musl;`SUBPACKAGE` 在 musl 上解析成 `-musl` 变体; 删掉 guard 3(npm 自己按 `libc` 选,postinstall 只需跟着找同一个名字)。 **🔴 P1:install.sh 在「glibc 发行版 + 装了 musl」上假阳性** 复审在 docker 里打了 7 个环境,5 个干净发行版全对,但: debian:bookworm-slim + musl-tools → botmux-linux-x64-musl ← 错 根因:Debian 的 `musl` 包在**顶层**建 `/lib/ld-musl-x86_64.so.1`,而我的 elif 链把 「ldd 没说 musl」当成「未知,继续探测」⟹ ldd 明明答了 glibc,还会被 loader 探测推翻。 受影响的是**搞 Rust/Go musl 交叉编译的人**(装 musl-tools 很常见)+ 走 curl 安装 ⟹ 装"成功"、一跑就 loader 报错。 修法:**ldd 存在 ⇒ 答案双向权威**(说 musl 是 musl,不说就是 glibc,不许落到 fs 探测); fs 探测只留给**没有 ldd** 的 distroless 类镜像。这与 #1047 postinstall 里 `glibcVersionRuntime 有值 ⇒ return false` 是同一个模式 —— 那份实现有权威负信号, install.sh 当初漏了。 ⭐**复审用我自己的 harness 证明了空洞**:`detectAsset({ lddOutput: 'GNU libc', muslLoader: true })` 返回 `-musl`,而我那 7 条测试**恰好没有这个组合**(「ldd 报 glibc」 那条的 fixture 没造 loader)。已补成回归用例。 **验证:终于跑了 npm-on-Alpine(复审指出这是标题功能却零证据)** 在 node:22-alpine 容器里,用真构建脚本 + 真 npm 安装走完整条链: inject 后 optionalDependencies → 6 个平台包全在(含两个 -musl) launcher → exec ".../botmux-linux-x64-musl/botmux" ← npm 选对了 通过 launcher 真开 PTY → PTY_OK⚠️ **第一次跑时 inject 步骤其实失败了**(`optionalDependencies` 只剩 canvas,上面还有个 Node 崩溃):我的探针把 inject 放在 `npm version` **之前**,而 inject 第 64 行要求两者 版本已一致 ⟹ 必然抛错。**这是 harness 顺序错、不是代码缺陷**(release.yml 的真实顺序 是先 version 再 inject),但它意味着那一轮的"launcher 指向 musl"只是因为我手动装了子包, **并没有证明注入清单是对的**。改成与 release.yml 同序后才拿到上面这份完整证据。 install.sh 三环境复验:本机 glibc → plain;node:22-alpine → `-musl`; debian:bookworm-slim → plain。新增 Debian+musl 那条回归。 其它:`PLATFORM_PACKAGES` 的注释还写着「四个」,一并改准。 测试:`install-sh-musl-asset` 8 条(含新回归);`npm-binary-distribution` 的两条断言按 新契约反转(inject 断言 6 个;musl 分支从「拒绝」改为「解析 -musl 名」并断言旧假话不 回来);分发 + install.sh + desktop 共 **176 测试全绿**;`bun run build` exit 0。⚠️ 修 fixture 时还发现:`install-sh-musl-asset` 的 PATH 含 `/usr/bin:/bin`,所以「没有 ldd」那两条 fixture **一直找得到系统 ldd**、实际走的是 ldd 分支 = 那两条此前零覆盖。 改成只挂 shim 目录 + 按需 symlink 几个工具。
* feat(release): 支持 musl(Alpine)二进制 —— 编译矩阵 4→6,npm 按 libc 自动选包 Alpine 与多数精简 Docker 镜像链接 musl libc,而 glibc 二进制在那里**根本跑不起来** ——死在 loader 里,报错不说明原因。此前 botmux 只发 glibc 二进制,而且这个失败是 **静默的**:npm 按 `os`/`cpu` 选平台子包,这两个字段 glibc 与 musl **完全相同** (`linux`/`x64`),所以 Alpine 上照样装 glibc 包、装"成功"、运行时才炸。 ## npm 有 `libc` 字段(`npm help package-json` 没写,但线上在用) ``` npm view @napi-rs/canvas-linux-x64-musl libc → musl npm view @napi-rs/canvas-linux-x64-gnu libc → glibc ``` 所以两个新 musl 子包声明 `libc: ["musl"]`,**同时给现有两个 linux 包补 `libc: ["glibc"]`** —— 只声明一半会让 glibc 包在 Alpine 上仍是候选、选择变歧义。 darwin 两个包不加(macOS 无 libc 变体这个概念)。 ## 🔴 musl 二进制必须在 musl 上编译(这决定了 CI 形态) `pty.node` 是**编译期嵌进二进制**的,而 node-pty **没有 linux 预编译**,所以 builder 会拿自己运行的 libc 去编。实测: | builder | `readelf -d pty.node` | |---|---| | glibc 机器 | `NEEDED libc.so.6` | | node:22-alpine | `NEEDED libc.musl-x86_64.so.1` | ⟹ 从 glibc runner 交叉编译 musl target 会嵌进**错误的 native**,并且**在构建期不报 错、只在用户 spawn PTY 时炸**。因此新增 `bun-binaries-musl` job 跑在 `container: node:22-alpine` 里(本仓库第一个容器化 job),并加一道 fail-closed: `readelf` 确认 `pty.node` 真是 musl 链接,否则拒绝继续。 `setup-bun` 不支持 musl 容器,改用 `npm i -g bun@1.4.0`;base image 缺 git/编译链, 先 `apk add`。 ## 端到端验证(这条是决定性的) 在 node:22-alpine 里用**仓库自己的构建脚本**(走 `makeNativeEmbedPlugin`)编出 `bun-linux-x64-musl`,然后**真的 spawn 一个 PTY**: ``` musl pty.node: 79,680 字节 ✅ built /work/probe (bun-linux-x64-musl) RESULT: ✅ PTY 真的在 musl 二进制里跑起来了 → PTY_OK_FROM_MUSL ```⚠️ **"二进制能启动"证明不了任何事** —— libc 不匹配恰恰是在**加载原生模块那一刻**炸, 而 `botmux status` 那条路径压根不加载 pty.node。所以验收必须落在真 spawn 上。 ⭐这个结论前后跑了 4 次才拿到,前 3 次全是**我自己探针的缺陷**、对 musl 零信息量: ① 手写 `createRequire` 入口绕开了 embed plugin ② tail 一个还没写入的日志 → "无输出" ③ `tail -4` 把 `error:` 首行截掉、只留 stack frame。而 ③ 修好后露出的报错证明我 **第 4 次又退回了 ①**(裸 `bun build --compile`,插件没跑)。教训写在 `scripts/build-bun-binary.mjs` 的注释里:验原生嵌入必须驱动真构建脚本。 ## install.sh 也要认 musl curl 安装那条路同样会拿错二进制。新增探测,**只在正面观测到 musl 时才切**(假阳性 会把每个普通 Linux 用户推向跑不了的 musl 包,比漏判更糟): `ldd --version` 抓 musl → `/lib/ld-musl-*` 与 `/usr/lib/ld-musl-*` → `/etc/alpine-release`。 (musl 的 ldd 对 `--version` 退出码非 0,所以只看输出不看状态。) 三环境实测:本机 glibc → `botmux-linux-x64`;node:22-alpine → `botmux-linux-x64-musl`; debian:bookworm-slim → `botmux-linux-x64`。 ## 新增测试 `test/install-sh-musl-asset.test.ts`(7 条) **执行从 install.sh 抽出的真实探测块**,把三个探针重定向到 fake root,而不是对脚本 文本做模式匹配(后者删掉逻辑照样绿)。覆盖 musl/glibc/无 ldd/仅 loader/仅 marker/ 全无线索六种形态 + 一条 source pin。⚠️ **反向变异逼出一个我自己的盲区**:把 `ldd --version | grep -qi musl` 换成 `true` (模拟"把所有 Linux 误判成 musl")时,第一版测试**全绿**。原因是那条 source pin 匹配 的是**整个文件**,而 block 的注释里恰好也写着 `ldd --version` —— **断言打在自己的说明 文字上**。改成先剥注释再比对,并补一条纯行为断言,现在同一变异 **4 条红**。 (这与本 PR 前一个 commit 里 `header.musl` 那次是同一形状,我在同一天犯了两次。) 其它反向变异:删掉 ldd 探测 → **5 条红**。 验证:`bun run build` exit 0;install.sh / release.yml 语法校验通过; 新测试 7/7;分发 + desktop + 新测试共 **21 文件 / 175 测试全绿**。 发布链接线:`bun-binaries-musl` 已接进 `release` / `binary-subpackages` / `attach-bun-binaries` 三处 `needs`,其中 `attach-bun-binaries` 带 `if:` ⟹ 按仓库既有 约定**显式**加了 `needs.bun-binaries-musl.result == 'success'`(GitHub 对带 `if:` 的 job 会丢掉隐式 all-needs-succeeded 门),否则 musl 腿失败时仍会附上不完整的资产集。 * fix(release): 接通 npm 侧 musl 选包链,并修 install.sh 的 glibc 假阳性 复审抓到两条,都在本 PR 范围内,且第一条是我**标题里承诺却没实现**的功能。 **🔴 P0:「npm 按 libc 自动选包」三处断链,一处都没接** 标题这么写,但 npm 那条路径我一行都没改,实测三处断: 1. `inject-optional-binaries.mjs` 的 `PLATFORM_PACKAGES` **硬编码 4 个** ⟹ 发布的主包 optionalDependencies 里**永远不会出现** musl 子包。而 `binary-subpackages` job 会把 6 个都发上 npm ⟹ **musl 包存在于 registry 上,却没有 任何东西引用它**,npm 连考虑的机会都没有。 2. `postinstall-bin.mjs` 的 `SUBPACKAGE` 无 libc 后缀 ⟹ 即使修了 1,Alpine 上 npm 装 的是 `-musl` 包,postinstall 却去找 glibc 名字,照样失败。 3. `isMuslLinux()` 那道 **hard fail 还活着** ⟹ Alpine 上 `npm i -g botmux` 会在解析子包 **之前**就被拦死,报错文案还是「cannot run on musl」——musl 二进制都发了,这句成了 假话。#1047 的注释自己预言过这天(「becomes a diagnostic」),预言兑现了,guard 没翻。 修法:`PLATFORM_PACKAGES` 加两个 musl;`SUBPACKAGE` 在 musl 上解析成 `-musl` 变体; 删掉 guard 3(npm 自己按 `libc` 选,postinstall 只需跟着找同一个名字)。 **🔴 P1:install.sh 在「glibc 发行版 + 装了 musl」上假阳性** 复审在 docker 里打了 7 个环境,5 个干净发行版全对,但: debian:bookworm-slim + musl-tools → botmux-linux-x64-musl ← 错 根因:Debian 的 `musl` 包在**顶层**建 `/lib/ld-musl-x86_64.so.1`,而我的 elif 链把 「ldd 没说 musl」当成「未知,继续探测」⟹ ldd 明明答了 glibc,还会被 loader 探测推翻。 受影响的是**搞 Rust/Go musl 交叉编译的人**(装 musl-tools 很常见)+ 走 curl 安装 ⟹ 装"成功"、一跑就 loader 报错。 修法:**ldd 存在 ⇒ 答案双向权威**(说 musl 是 musl,不说就是 glibc,不许落到 fs 探测); fs 探测只留给**没有 ldd** 的 distroless 类镜像。这与 #1047 postinstall 里 `glibcVersionRuntime 有值 ⇒ return false` 是同一个模式 —— 那份实现有权威负信号, install.sh 当初漏了。 ⭐**复审用我自己的 harness 证明了空洞**:`detectAsset({ lddOutput: 'GNU libc', muslLoader: true })` 返回 `-musl`,而我那 7 条测试**恰好没有这个组合**(「ldd 报 glibc」 那条的 fixture 没造 loader)。已补成回归用例。 **验证:终于跑了 npm-on-Alpine(复审指出这是标题功能却零证据)** 在 node:22-alpine 容器里,用真构建脚本 + 真 npm 安装走完整条链: inject 后 optionalDependencies → 6 个平台包全在(含两个 -musl) launcher → exec ".../botmux-linux-x64-musl/botmux" ← npm 选对了 通过 launcher 真开 PTY → PTY_OK⚠️ **第一次跑时 inject 步骤其实失败了**(`optionalDependencies` 只剩 canvas,上面还有个 Node 崩溃):我的探针把 inject 放在 `npm version` **之前**,而 inject 第 64 行要求两者 版本已一致 ⟹ 必然抛错。**这是 harness 顺序错、不是代码缺陷**(release.yml 的真实顺序 是先 version 再 inject),但它意味着那一轮的"launcher 指向 musl"只是因为我手动装了子包, **并没有证明注入清单是对的**。改成与 release.yml 同序后才拿到上面这份完整证据。 install.sh 三环境复验:本机 glibc → plain;node:22-alpine → `-musl`; debian:bookworm-slim → plain。新增 Debian+musl 那条回归。 其它:`PLATFORM_PACKAGES` 的注释还写着「四个」,一并改准。 测试:`install-sh-musl-asset` 8 条(含新回归);`npm-binary-distribution` 的两条断言按 新契约反转(inject 断言 6 个;musl 分支从「拒绝」改为「解析 -musl 名」并断言旧假话不 回来);分发 + install.sh + desktop 共 **176 测试全绿**;`bun run build` exit 0。⚠️ 修 fixture 时还发现:`install-sh-musl-asset` 的 PATH 含 `/usr/bin:/bin`,所以「没有 ldd」那两条 fixture **一直找得到系统 ldd**、实际走的是 ldd 分支 = 那两条此前零覆盖。 改成只挂 shim 目录 + 按需 symlink 几个工具。 * ci(musl): PR CI 也编译并跑 musl 二进制,不再只在发版时验 复审 approve 后收到一个提问:「不能跟其他包类似的编译方式吗,CI 没有相关镜像吗」。 镜像有(`node:22-alpine`,跑在普通 runner 上),交叉编译确实不行;但顺着这个问题 查出一个我自己的遗漏。 **遗漏:musl job 只加在了 release.yml** `bun-binaries-musl` 只在 tag push 时跑。这意味着 PR 改了构建脚本、embed plugin 或 node-pty 版本,musl 这条腿**第一次被执行是在发版流程里**——npm 已经发出去之后。 其它 4 个平台都有 PR 门(`bun-binary` job),只有 musl 没有。 ci.yml 补 `bun-binary-musl`:镜像 + apk 工具链 + `npm i -g bun@1.4.0` (setup-bun 不支持 musl 容器)+ readelf 硬校验 + 编 x64-musl + 跑同一份 smoke。 只编 x64:GitHub 无 Alpine runner 所以必须容器化,arm64 还要另一个更慢的 runner;release 仍编两个 arch,测试里钉住这个不对称不许被"对齐"掉。 **为什么没加复审建议的 PTY spawn 步骤——变异实测给了答案** 原计划顺手加上,先测了一下它能覆盖什么。把嵌进去的 `pty.node` 保留 ELF 头、 正文清零(= glibc native 装在 musl 二进制里的运行期失败形态),用**真** build 脚本 编出来跑现有 smoke: smoke 第 1 项就死:ERR_DLOPEN_FAILED: object file has no loadable segments exit 1,一个 ✅ 都没打出来 根因是 `dist/cli.js` 静态 import node-pty(经 backends),而 node-pty 在**模块作用域** 就 dlopen native。所以「二进制能跑起来」已经等价于「native 能加载」——单独加一步 PTY spawn 只多覆盖 forkpty 系统调用本身,而那部分与 libc 匹配无关。⚠️ 这个结论**只针对「加载」,不针对「spawn 正确」**;真 PTY 证据仍来自本机容器实测。 smoke 该覆盖哪些层是独立话题,留 follow-up。 ⭐ 教训:先问「现有门是否已经拦得住」再加门。我差点加一步**已被覆盖**的检查, 还会顺带改动 glibc 那几条腿的 smoke。 **验证** 在 `node:22-alpine` 里把新 job 的 9 步逐条 dry-run(源码 copy 时**排除 node_modules**——它是指向 canonical 的 symlink,绝不能被 install 穿透写入): bun install --frozen-lockfile → 698 packages(提交的 bun.lock 在 musl 上可用) readelf → NEEDED libc.musl-x86_64.so.1 compile → version=0.0.0-ci.99(不是 unknown 哨兵) smoke(仓库相对路径,与 ci.yml 逐字一致)→ 六项全 ✅⚠️ 特意用**仓库相对路径**跑 smoke:本 PR 之前踩过「相对路径 + spawn cwd 解歪成 ENOENT」,本机绝对路径会掩盖它。步序也与 release.yml 一致(install → npm version → compile),因为 `--frozen-lockfile` 必须看到未被改写的 package.json。 新增 `test/ci-musl-gate.test.ts` 9 条,钉的是「PR 门不弱于发版门」这个不变量, 不是 YAML 形状。6 条反向变异全部转红:删 job / 去容器 / 换成 glibc target / 删 readelf / 删 smoke / 把 npm version 挪到 install 之前。断言前先剥掉 `#` 注释—— 否则一句**描述**该检查的注释就能让断言通过。 37 测试全绿(分发 + install.sh + 本文件);`bun run build` exit 0。
|
🚀 Released in v3.18.0 |
编译版单文件二进制下 `botmux update`、Dashboard 手动更新、定时自动更新三处全部失效。 真实发布的 v3.18.4 二进制端到端复现:`--version` 正常但 `update` 报「无法安全识别当前 安装方式(unknown)」。根因是三处都按「install root 的路径形态」判包管理器,而编译版 没有 package.json 落盘 ⟹ 该根解析为 `/`。 关键点:**npm 装的与 install.sh 装的是同一个编译版二进制**(#1047 去掉 `bin` 之后), 所以按模块图判形态必然失败,改按 `process.execPath` 的实际位置判:npm/pnpm/Bun 平台 子包形态交回原包管理器;install.sh 独立安装形态自己下载资产、校验 SHA-256、原子替换。 顺带修掉同源缺陷:`isLocalDevInstall` 在存在 `/src` 的镜像中误判编译版为源码 checkout; 分离式重启传参在编译态错位导致「报告成功但未重启」;内建版本号遮蔽更新结果使定时任务 误判「已是最新」而跳过重启、Dashboard 变更标记恒假、版本回退每次误报不匹配;pnpm 版本化 store 更新后可能由旧二进制拉起;回退入口曾展示给不支持回退的安装形态;并发更新 落败方曾把锁内部状态当故障透给用户(现按超时/临界区内/临界区前三态分流)。 Node 安装形态行为逐字不变。 验证:tsc 0 错;CI 7/7 全绿;相关测试 42 条 + 全量单测 19078 passed(8 个既有失败已与 干净基线逐文件对照);17 组反变异全红;真二进制 A/B 实测 PRE 报 unknown、POST 真下载 真替换且替换后可正常运行。
改了什么
npm i -g botmux之前会从源码编译一次 node-pty。主包dependencies挂着它,而 node-pty 没有 linux 预编译(prebuilds/只有 darwin-arm64 / darwin-x64 / win32-x64 / win32-arm64),所以 Linux 上必走 node-gyp。真正的代价不是那几秒,是把一整条编译工具链变成安装前提(python3 / make / gcc)——这个前提在很多机器和精简镜像里根本不成立,那里不是慢,是直接装不上。
而那份编译产物并非无用:它是
bin: {botmux: "dist/cli.js"}这条 Node 降级路径的真实依赖(dist/adapters/backend/pty-backend.js确实require('node-pty'),postinstall-bin.mjs原注释也明写这是有意的兜底)。所以不能只删dependencies—— 那是最糟组合:fallback 仍然挂着,一旦走到就Cannot find module 'node-pty'。本 PR 把这条路径整条拆掉,五处一起改:
node-pty+@napi-rs/canvas→devDependenciesbuild-bun-binary.mjs:89要require.resolve('node-pty/package.json')取pty.node嵌进二进制,canvas 同理(卡片渲染)。从「用户装包要编的运行时依赖」变成「我们编二进制要用的构建期依赖」binbail()(warn+exit 0) →fail()(exit 1)botmux命令,报错要等到很久以后。三个 guard 分支(非全局装 / 源码 checkout 内)仍静默 exit 0——那不是失败,是「无事可做」os/cpu选平台子包,而 glibc 与 musl 这两个字段完全相同(linux/x64)→ Alpine 上照样装 glibc 包、运行时挂在不说明原因的 loader 错误上。装"成功"了但跑不了,比明确失败糟得多The botmux command still works via Node if this platform is unsupported—— 删了 fallback 这句是骗人musl 检测的实现选择
优先用
process.report.getReport().header.glibcVersionRuntime(比翻文件系统权威),回落/lib/ld-musl-*与/etc/alpine-release,并且只在正面观测到 musl 时才判定,避免 glibc 机器被假阳性拦住。Windows
原文档写「Windows 仍走
npm i -g botmux」,删掉 fallback 后这句不成立。正解是 WSL2:它报告为linux、跑真 Linux 内核,PTY / tmux / 信号全正常,是完整支持的一等环境。原生 Windows 本来也跑不了 daemon。README 与失败提示都改成指向 WSL2。影响面
install-diagnostics.ts的'pnpm-global'探测、maintenance.ts自动更新一行没动(那是产品语义,线上确实有人pnpm i -g botmux)。验证
① 端到端:真
npm i -g(隔离 HOME + prefix,未碰生产~/.botmux——那个 launcher 被 ~50 个 live daemon 共享)(这里失败是因为本地版本号
0.0.0没有对应已发布子包 —— 正好把新契约演示出来。真实发版时子包存在,会正常写 launcher。)② 二进制仍能编出(devDependencies 够用):
bun scripts/build-bun-binary.mjs成功;strings在产物里查到 4 处pty.node⟹ 真嵌进去了,不是"能编但没嵌"。③ musl 检测双向验证(只验通过方向会漏掉假阳性):
falsetruefalse)④ 反向变异 3 处全有牙:
bin加回 → 红(expected { botmux: 'dist/cli.js' } to be undefined)node-pty挪回dependencies→ 红(expected '^1.1.0' to be undefined)fail()改回exit 0→ 红⑤ 测试:新增 2 条 source pin 防回退;
npm-binary-distribution16/16;相关 22 文件 / 199 测试全绿;bun run buildexit 0。原有那条
missing platform subpackage → warns but EXITS 0断言按新契约反转为「必须失败」,并加断言确保旧假提示不会回来。🔄 R3 更新:改法从 optionalDependencies 升级为「node-pty 完全退出安装链路」
复审指出 R2 留下一个 P1:
optionalDependencies仍会编译。npm 会安装 optional 依赖并跑它的 install script(node scripts/prebuild.js || node-gyp rebuild),而 node-pty 没有 linux 预编译 ⟹ 有工具链的机器上每次npm i -g botmux仍从源码编一遍。而 README 里「安装过程不编译任何原生模块」这句是本 PR 加的 ⟹ 带着已知假话不能合。申晗定「修到底」。R2 我报「全树零
pty.node,主目标没回退」。那是个假阴性:我 pack 的是本地0.0.0包,没有对应平台子包 ⟹ postinstall 必然 exit 1 ⟹ npm 在 postinstall 失败时回滚整个node_modules。真实时序是build/Release/pty.node改成本地装(postinstall 走 guard 静默 exit 0、树保留)立刻看到
gyp http GET node-headers和 71,576 字节产物。注定失败的 fixture 对成功场景零证据。最终改法
node-ptydevDependenciesdependencies或optionalDependencies)都会触发 node-gyp。用户根本不需要它:二进制在编译期嵌入pty.node,桌面 runtime 靠复制拿到@napi-rs/canvasoptionalDependencies--cpu '*'拿双架构 —— 那只对--production会装的依赖生效桌面 runtime 新增
stageNodePty():bun install --production拿不到 devDependencies,所以从 builder 自己的node_modules复制进去。可行依据:node-pty loader 顺序是build/Release → build/Debug → prebuilds/<platform>-<arch>(lib/utils.js),npm 包自带 darwin 双架构 prebuilds ⟹ macOS 从 prebuild 加载,桌面侧本来就不需要编译。🔴 复制时必须排除
build/(我自己的断言逮到的)node-pty loader 先看
build/Release,所以 builder 树上任何编译残留都会被 cp 带进去并遮蔽正确的 prebuild。残留的现实来源三条:Linux 开发机(无 linux 预编译 ⟹ 必编)、
npm_config_build_from_source=true、未来 node-pty 停发某个 darwin 预编译。已加
filter+ 两条 fail-closed 断言:darwin prebuild 必须存在;builder 的build/Release/pty.node必须不存在。R3 验证
exit 0、277 包保留、全树零pty.node、node-pty 未安装。对照:同一 harness 在 R2 会打出 gyp 日志build/未复制;并独立于 filter 代码再验一次 —— staged node-pty 里 ELF 数 = 0isUnderBuildDir边界实跑 7 例全对(含buildinfo.txt前缀假阳性、lib/build.js)await stageNodePty();→ 红;去掉filter:行 → 红isUnderBuildDir标识符存在,而删掉filter:行标识符还在。改成正则匹配「filter 确实接进了 cp」才有牙 —— 与本 PR 前两轮被抓的是同一形状:断言打在"看起来相关"的东西上,而非真正承重那处build曾红在mojo-close-worker-journal.integration.test.ts(本 PR 未碰):该文件把超时从真实 75s mock 成 4s,同轮日志里邻近用例跑了 5,329ms ⟹ 负载敏感;同 SHA--failed重跑全绿。本机隔离 7/7、并发 17/17。已记进基线,并另开 issue 建议「余量按机器性能给而非钉死 4s」bun run buildexit 0;二进制仍能编出(strings查到 4 处pty.node)仍未被 CI 覆盖(必须说明)
桌面链(
desktop:runtime/desktop:bundle/ electron-builder)只在发正式版时于 macOS runner 跑,结构上不在 PR 的六项检查里。所以本 PR 桌面部分的信心全部来自本机复刻验证,不来自那六个绿勾。