Skip to content

chore(npm): 去掉 Node fallback,安装不再编译原生模块 - #1047

Merged
deepcoldy merged 5 commits into
masterfrom
chore/drop-node-fallback
Aug 28, 2026
Merged

chore(npm): 去掉 Node fallback,安装不再编译原生模块#1047
deepcoldy merged 5 commits into
masterfrom
chore/drop-node-fallback

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Aug 27, 2026

Copy link
Copy Markdown
Owner

改了什么

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 把这条路径整条拆掉,五处一起改:

# 改动 为什么
1 node-pty + @napi-rs/canvasdevDependencies 不是删除build-bun-binary.mjs:89require.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 上照样装 glibc 包、运行时挂在不说明原因的 loader 错误上。装"成功"了但跑不了,比明确失败糟得多
5 三处会变成假话的提示 + README 两处 最要紧的是 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 机器被假阳性拦住。

npm 其实支持 libc 字段——npm help package-json 没写但线上在用:npm view @napi-rs/canvas-linux-x64-musl libcmusl。等 botmux 也发 musl 子包之后,npm 会自己选对,这个守卫就从「不支持 musl」降级成「musl 包没装上」的诊断。加 musl 目标是下一个 PR,本 PR 先把静默失败变成明确失败。

Windows

原文档写「Windows 仍走 npm i -g botmux」,删掉 fallback 后这句不成立。正解是 WSL2:它报告为 linux、跑真 Linux 内核,PTY / tmux / 信号全正常,是完整支持的一等环境。原生 Windows 本来也跑不了 daemon。README 与失败提示都改成指向 WSL2。

影响面

  • **只影响「终端用户怎么装 botmux」**这一条链路(主包 manifest + postinstall)。不碰 daemon / worker / CLI 适配器 / IM 层任何运行时代码。
  • install-diagnostics.ts'pnpm-global' 探测、maintenance.ts 自动更新一行没动(那是产品语义,线上确实有人 pnpm i -g botmux)。
  • 副作用:四个支持平台(linux/darwin × x64/arm64)之外改为装不上而非降级 Node。考虑到 daemon 本就 Unix-only,判断是对的 ——「装上了但不能用」比「装不上并说清原因」更糟。

验证

① 端到端:真 npm i -g(隔离 HOME + prefix,未碰生产 ~/.botmux——那个 launcher 被 ~50 个 live daemon 共享)

打出的包: botmux-0.0.0.tgz (9.1M)

npm error [botmux] no prebuilt binary package for linux-x64 (botmux-linux-x64).
npm error [botmux] Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64.
                   On Windows, run botmux inside WSL2 (it reports as linux and is fully supported).
npm exit=1

=== 有没有编译 node-pty ===  ✅ 全树零 pty.node —— 没有任何编译
=== 有没有误写 launcher ===  ✅ 没写(符合 fail-hard)

(这里失败是因为本地版本号 0.0.0 没有对应已发布子包 —— 正好把新契约演示出来。真实发版时子包存在,会正常写 launcher。)

② 二进制仍能编出(devDependencies 够用):bun scripts/build-bun-binary.mjs 成功;strings 在产物里查到 4 处 pty.node ⟹ 真嵌进去了,不是"能编但没嵌"。

③ musl 检测双向验证(只验通过方向会漏掉假阳性):

  • 本机 glibc 2.36 → false
  • 伪造 musl report → true
  • report 缺失 → 回落文件系统探测(本机 → false

④ 反向变异 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-distribution 16/16;相关 22 文件 / 199 测试全绿bun run build exit 0。

原有那条 missing platform subpackage → warns but EXITS 0 断言按新契约反转为「必须失败」,并加断言确保旧假提示不会回来。


🔄 R3 更新:改法从 optionalDependencies 升级为「node-pty 完全退出安装链路」

复审指出 R2 留下一个 P1:optionalDependencies 仍会编译。npm 会安装 optional 依赖并跑它的 install scriptnode scripts/prebuild.js || node-gyp rebuild),而 node-pty 没有 linux 预编译 ⟹ 有工具链的机器上每次 npm i -g botmux 仍从源码编一遍。而 README 里「安装过程不编译任何原生模块」这句是本 PR 加的 ⟹ 带着已知假话不能合。申晗定「修到底」。

⚠️ 先更正 R2 里我一条错误的验证结论

R2 我报「全树零 pty.node,主目标没回退」。那是个假阴性:我 pack 的是本地 0.0.0 包,没有对应平台子包 ⟹ postinstall 必然 exit 1 ⟹ npm 在 postinstall 失败时回滚整个 node_modules。真实时序是

  1. 装 optional → 跑 node-pty install script → node-gyp 编出 build/Release/pty.node
  2. postinstall 失败
  3. npm 删掉整棵树
  4. 我在被删空的树上数 pty.node → 零

改成本地装(postinstall 走 guard 静默 exit 0、树保留)立刻看到 gyp http GET node-headers 和 71,576 字节产物。注定失败的 fixture 对成功场景零证据。

最终改法

位置 为什么
node-pty devDependencies 有 install script + 无 linux 预编译 ⟹ 任何「用户安装会认的位置」(dependenciesoptionalDependencies)都会触发 node-gyp。用户根本不需要它:二进制在编译期嵌入 pty.node,桌面 runtime 靠复制拿到
@napi-rs/canvas optionalDependencies 无 install script(永不编译),且桌面树要靠 --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 预编译。

⚠️最坏情况静默且按 arch 分裂:macOS builder 编出的是单 arch Mach-O,会遮蔽 Universal 包里另一个 arch 的 prebuild ⟹ 一半机器终端挂掉,构建期什么都不报。

已加 filter + 两条 fail-closed 断言:darwin prebuild 必须存在;builder 的 build/Release/pty.node 必须不存在。

📝 一处自我更正:我最初把理由写成「builder 是 Linux,那是 Linux ELF」。实际 release.ymldesktop job 跑在 macos-14,且 macOS 上 prebuild.js 因预编译存在而 exit(0)不会产生 build/。我拿本机观测当成了发布路径事实。结论(保留防御)对,但理由错了同样危险 —— 它会让下一个人按错误模型判断这个防御还需不需要。注释已改准。

R3 验证

  • 零编译(落在成功路径上):本地装真 tarball → exit 0277 包保留全树零 pty.node、node-pty 未安装。对照:同一 harness 在 R2 会打出 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
  • 反向变异:删 await stageNodePty(); → 红;去掉 filter: 行 → 红
    • ⚠️后者第一版没抓到:我只断言了 isUnderBuildDir 标识符存在,而删掉 filter: 行标识符还在。改成正则匹配「filter 确实接进了 cp」才有牙 —— 与本 PR 前两轮被抓的是同一形状:断言打在"看起来相关"的东西上,而非真正承重那处
  • CI 六项 SUCCESS。⚠️其中 R3 首轮 build 曾红在 mojo-close-worker-journal.integration.test.ts本 PR 未碰):该文件把超时从真实 75s mock 成 4s,同轮日志里邻近用例跑了 5,329ms ⟹ 负载敏感;同 SHA --failed 重跑全绿。本机隔离 7/7、并发 17/17。已记进基线,并另开 issue 建议「余量按机器性能给而非钉死 4s」
  • 分发 + desktop 20 文件 / 168 测试全绿bun run build exit 0;二进制仍能编出(strings 查到 4 处 pty.node

仍未被 CI 覆盖(必须说明)

桌面链(desktop:runtime / desktop:bundle / electron-builder)只在发正式版时于 macOS runner 跑,结构上不在 PR 的六项检查里。所以本 PR 桌面部分的信心全部来自本机复刻验证,不来自那六个绿勾。

`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 验证。
@deepcoldy

Copy link
Copy Markdown
Owner Author

先说结论:这个 PR 的方向我们认为是对的——node-pty 在 Linux 上没有预编译、必然走 node-gyp,把「一整条编译工具链」变成安装前提确实是真痛点;而「不能只删 dependencies、否则 fallback 还挂着会 Cannot find module」这个判断也很准,五处一起改的拆法是完整的。musl 检测那一节尤其到位:os/cpu 对 glibc 与 musl 完全相同,「装成功了但跑不了」比明确失败糟得多,这个取舍我们同意。

不过我们在复审中发现同一个「最糟组合」在另一条路上又出现了一次,想请你在合入前看一眼。

🔴 --production 恰好排除 devDependencies → desktop 打包链被打断

node-pty / @napi-rs/canvas 挪进 devDependencies 之后,scripts/prepare-desktop-runtime.mjs:50 那句 bun install --production 就再也装不到它们了(--production 的语义正是「排除 devDependencies」),而 desktop app 确实会用捆绑的 Node 去跑 runtime/dist/cli.jssrc/desktop/main/runtime-service.ts:171),那条路需要 node-pty

我们用 desktop 的原样调用形态做了 master / PR 对照实测(bun install --production --frozen-lockfile --ignore-scripts --os darwin --cpu '*'):

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:70assertBundledCanvasArchitectures() 必然 throw(Bundled runtime is missing @napi-rs/canvas-darwin-arm64)→ release.yml:79bun run desktop:runtime 失败 → 发版时 desktop job 挂
  • 运行期(假设绕过该断言):把 PR 的 dist 放进上述 staged 树,node dist/cli.js --helpexit 1ERR_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 同样会死。

两个让它不容易被发现的因素

  1. 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 全绿并不覆盖这条路径
  2. 现有测试是零覆盖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.mjsisMuslLinux() 中:

if (header.musl || header.muslVersionRuntime) return true;

我们在真 Alpine 容器里实测(node:22-alpine):header.muslheader.muslVersionRuntime 都是 undefined——Node 的 report header 只有 glibcVersionRuntime / glibcVersionCompiler 两个 libc 相关键。实际生效的是 /lib/ld-musl-* 那条回落。

功能本身是正确的,双向都验过了:真 Alpine → truenode: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 build exit 0;npm-binary-distribution 16/16;相关 21 文件 / 175 测试全绿。
  • install.sh(curl 那条)不受影响;install-diagnostics.tspnpm-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 ⟹ 一半机器上终端功能挂掉,而构建
期什么都不报。

⭐记录这次错误的形状:结论(保留防御)恰好正确,但**理由错了同样危险**——它会让下一个
人按错误的模型判断「这个防御还需不需要」。
@deepcoldy
deepcoldy merged commit be25b69 into master Aug 28, 2026
6 of 7 checks passed
deepcoldy added a commit that referenced this pull request Aug 28, 2026
复审抓到两条,都在本 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 几个工具。
deepcoldy added a commit that referenced this pull request Aug 28, 2026
* 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。
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.18.0

deepcoldy added a commit that referenced this pull request Aug 29, 2026
编译版单文件二进制下 `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 真下载
真替换且替换后可正常运行。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant