Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ botmux start # 启动 daemon(botmux autostart enable 设开机
```

> npm 包内已经带了对应平台的**自包含二进制**(按 os/arch 只装匹配的那一个),安装时会把 `~/.botmux/bin/botmux` 指向它。所以装完只有**一个** botmux 版本,不再出现「装了两个 Node 版本、各自带一份全局 botmux 互相打架 / 不知道更新了哪个」。把 `~/.botmux/bin` 放进 PATH 即可(安装日志会提示命令)。
>
> 安装过程**不编译任何原生模块**(不需要 Python / node-gyp / 编译器):要跑的 PTY 已经嵌在那个二进制里,npm 只是把它放到位。支持 linux / macOS × x64 / arm64;**Windows 请在 WSL2 里安装**(WSL 报告为 linux,是完整支持的一等环境)。不在支持列表里的平台会在安装时**明确报错**,而不是装上一个跑不起来的命令。

<details>
<summary>不想装 Node?直接下单文件可执行(连装包都不需要 Node)</summary>
Expand All @@ -57,7 +59,7 @@ botmux setup
botmux start
```

装到 `~/.botmux/bin/botmux`(`BOTMUX_INSTALL_DIR` 可改),自动按 OS/arch 拉对应二进制并校验 SHA-256。命令用法与 npm 版完全一致。Windows 仍走 `npm i -g botmux`(daemon Unix-only)。
装到 `~/.botmux/bin/botmux`(`BOTMUX_INSTALL_DIR` 可改),自动按 OS/arch 拉对应二进制并校验 SHA-256。命令用法与 npm 版完全一致。**Windows 请在 WSL2 里安装**(daemon 依赖 PTY / tmux / Unix 信号,原生 Windows 跑不了;WSL2 报告为 linux,完整支持)。

</details>

Expand Down
11 changes: 5 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
{
"name": "botmux",
"version": "0.0.0",
"description": "Bridge between IM platforms and AI coding CLIs one topic, one CLI session with live streaming",
"description": "Bridge between IM platforms and AI coding CLIs \u2014 one topic, one CLI session with live streaming",
"type": "module",
"main": "dist/index-daemon.js",
"bin": {
"botmux": "dist/cli.js"
},
"files": [
"dist/",
"scripts/postinstall-bin.mjs",
Expand Down Expand Up @@ -66,13 +63,11 @@
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.64.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/canvas": "^0.1.65",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/headless": "^6.0.0",
"croner": "^10.0.1",
"dotenv": "^17.3.1",
"markdown-it": "^14.1.1",
"node-pty": "^1.1.0",
"pm2": "^6.0.0",
"proxy-agent": "^6.5.0",
"qrcode-terminal": "^0.12.0",
Expand All @@ -82,6 +77,9 @@
"yaml": "^2.9.0",
"zod": "^3.23.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.65"
},
"devDependencies": {
"@midscene/web": "^1.7.6",
"@playwright/test": "^1.58.2",
Expand All @@ -96,6 +94,7 @@
"electron-builder": "^26.0.0",
"esbuild": "^0.28.0",
"memfs": "^4.57.1",
"node-pty": "^1.1.0",
"playwright": "^1.58.2",
"react-test-renderer": "19.2.7",
"tsx": "^4.20.5",
Expand Down
103 changes: 87 additions & 16 deletions scripts/postinstall-bin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,78 @@
* daemons). Hence the guard is a STRICT `=== "true"`, and there is a second,
* independent bail-out when we can see we are inside the source checkout.
*
* ── FAIL SOFT, ALWAYS ───────────────────────────────────────────────────────────
* A postinstall that throws aborts `npm i -g`. Nothing here is worth failing an
* install over: if we cannot find the binary or cannot write the launcher, we warn
* with actionable text and exit 0. npm's own `bin` shim still exists as a fallback
* path, so the user is never left with nothing.
* ── FAIL HARD WHEN THERE IS NO BINARY ──────────────────────────────────────────
* This used to warn and exit 0, because `bin: {botmux: "dist/cli.js"}` gave every
* failure a Node fallback to land on. That fallback is gone (it forced the main
* package to depend on node-pty, which has no linux prebuild and so pulled a whole
* node-gyp toolchain into every `npm i -g` — an install-time requirement that
* simply is not met on many machines). With no fallback, exiting 0 without a
* launcher would leave the user with a `botmux` command that does not exist, and
* they would find out later with a confusing error. So: no binary → fail the
* install, loudly, with the reason.
*
* The GUARD cases below still exit 0 silently — those are not failures, they are
* "this is not a global install, there is nothing to do".
*/

import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, renameSync, unlinkSync, realpathSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, chmodSync, renameSync, unlinkSync, realpathSync, statSync } from 'node:fs';
import { dirname, join, basename } from 'node:path';
import { homedir } from 'node:os';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';

const SUBPACKAGE = `botmux-${process.platform}-${process.arch}`;

/** Warn + exit 0. Never fail the install (see header). */
function bail(reason, hint) {
console.warn(`[botmux] ${reason}`);
if (hint) console.warn(`[botmux] ${hint}`);
process.exit(0);
/** Abort the install with a reason. There is no Node fallback any more (see header). */
function fail(reason, hint) {
console.error(`[botmux] ${reason}`);
if (hint) console.error(`[botmux] ${hint}`);
process.exit(1);
}

/**
* Are we on a musl libc distro (Alpine and most slim Docker images)?
*
* WHY THIS CHECK EXISTS: npm selects platform subpackages by `os`/`cpu`, and both
* are identical for glibc and musl (`linux`/`x64`), so on Alpine npm happily
* installs `botmux-linux-x64` — a glibc-linked binary that dies at exec time with a
* loader error naming no cause. Without this check we would write a launcher
* pointing at a binary that cannot run: the worst outcome, because the install
* "succeeds" and the failure surfaces much later.
*
* (npm does support a `libc` field — undocumented in `npm help package-json` but
* live in the wild, e.g. `@napi-rs/canvas-linux-x64-musl` publishes `libc: musl`.
* Once botmux ships musl subpackages declaring it, npm will pick the right one on
* its own and this guard becomes a diagnostic for "the musl package failed to
* install" rather than "musl is unsupported".)
*
* Detection order is authoritative-first, and deliberately conservative: only claim
* musl when positively observed, so a glibc box is never blocked by a false
* positive.
*
* MEASURED on node:22-alpine: `process.report.getReport().header` has 23 keys and
* carries NEITHER `glibcVersionRuntime` NOR any musl key. So the report is only
* useful as a NEGATIVE signal ("glibcVersionRuntime present ⇒ definitely glibc,
* stop"); on musl it tells us nothing and the loader probe below is what actually
* decides. Do not add a `header.musl` branch back — Node does not publish one.
*/
function isMuslLinux() {
if (process.platform !== 'linux') return false;
// Negative signal only (see above): a reported glibc runtime settles it.
try {
if (process.report?.getReport?.()?.header?.glibcVersionRuntime) return false;
} catch { /* report unavailable; fall through to filesystem probes */ }
// The ld-musl loader is the direct positive evidence.
for (const dir of ['/lib', '/usr/lib']) {
try {
if (readdirSync(dir).some(f => f.startsWith('ld-musl-'))) return true;
} catch { /* unreadable; try the next probe */ }
}
// Alpine's marker file, for images that moved the loader.
try {
if (existsSync('/etc/alpine-release')) return true;
} catch { /* ignore */ }
return false;
}

// ── Guard 1: only a real `npm i -g` (strict equality; see header) ───────────────
Expand All @@ -68,6 +120,19 @@ if (existsSync(join(pkgRoot, '.git')) && existsSync(join(pkgRoot, 'src'))) {
process.exit(0);
}

// ── Guard 3: musl (Alpine) would install a glibc binary that cannot exec ────────
// Checked BEFORE resolving the subpackage, because on Alpine the subpackage does
// resolve (platform is `linux`) — so the generic "no prebuilt binary" message below
// would never fire and we would happily point the launcher at a binary that dies at
// startup. Fail here with the actual reason instead.
if (isMuslLinux()) {
fail(
`botmux's prebuilt binaries are glibc-linked and cannot run on musl libc (Alpine).`,
'Use a glibc base image (e.g. node:22-bookworm / debian / ubuntu), or build from source. '
+ 'Tracking musl builds: https://github.com/deepcoldy/botmux/issues',
);
}

// ── Locate the platform binary ─────────────────────────────────────────────────
// Resolve through Node's own resolver rather than guessing at node_modules layout:
// npm, pnpm, and yarn lay out global installs differently (nested, hoisted,
Expand All @@ -80,15 +145,18 @@ try {
const manifest = require.resolve(`${SUBPACKAGE}/package.json`);
binary = join(dirname(manifest), 'botmux');
} catch {
bail(
fail(
`no prebuilt binary package for ${process.platform}-${process.arch} (${SUBPACKAGE}).`,
// Do NOT say "it still works via Node" — that fallback is gone (see header).
// On Windows the daemon cannot run natively at all (PTY/tmux/Unix signals), so
// WSL is the real answer there, not a Node install.
'Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64. '
+ 'The `botmux` command still works via Node if this platform is unsupported.',
+ 'On Windows, run botmux inside WSL2 (it reports as linux and is fully supported).',
);
}

if (!existsSync(binary)) {
bail(
fail(
`${SUBPACKAGE} is installed but its binary is missing (${binary}).`,
'Try reinstalling: npm i -g botmux --force',
);
Expand Down Expand Up @@ -143,9 +211,12 @@ try {
}
console.log(`[botmux] launcher → ${binary}`);
} catch (err) {
bail(
fail(
`could not write the launcher at ${launcher}: ${err && err.message ? err.message : String(err)}`,
'The `botmux` command may still work via npm\'s own shim.',
// NOT "npm's own shim still works" — `bin` was removed with the Node fallback,
// so there is no other `botmux` on PATH. Give the user something they can act on.
`Fix the permissions on ${binDir} and retry, or set BOTMUX_INSTALL_DIR and use the `
+ 'standalone installer: curl -fsSL https://raw.githubusercontent.com/deepcoldy/botmux/master/install.sh | sh',
);
}

Expand Down
77 changes: 77 additions & 0 deletions scripts/prepare-desktop-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { homedir, tmpdir } from 'node:os';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { spawnSync } from 'node:child_process';
import https from 'node:https';

Expand Down Expand Up @@ -54,6 +55,7 @@ async function stageBotmuxRuntime() {
'--cpu', '*',
], runtimeDir);
await assertBundledCanvasArchitectures();
await stageNodePty();
// electron-builder applies the app-level `!node_modules/**` exclusion to
// extraResources and expands pnpm symlinks into duplicate dependency trees.
// A single archive crosses that boundary intact; afterPack expands it before
Expand Down Expand Up @@ -83,6 +85,81 @@ async function assertBundledCanvasArchitectures() {
}
}

/**
* Copy node-pty into the staged runtime tree.
*
* WHY A COPY INSTEAD OF A DEPENDENCY: node-pty is a devDependency on purpose. It has
* an `install` script (`node scripts/prebuild.js || node-gyp rebuild`) and ships NO
* linux prebuild, so any placement npm/bun would install for an end user turns a
* node-gyp toolchain into an install prerequisite — measured: `npm i -g botmux` then
* compiles pty.node from source on every machine that has a compiler. The CLI does
* not need it installed at all (the single-file binary embeds pty.node at compile
* time), and the desktop app does not need it BUILT: node-pty's loader tries
* `build/Release` → `build/Debug` → `prebuilds/<platform>-<arch>` (lib/utils.js), and
* the npm package carries darwin-arm64 + darwin-x64 prebuilds. Under
* `--ignore-scripts` `build/Release` never exists, so macOS loads a prebuild.
*
* So: stage the package as-is from the builder's own node_modules. Its only
* dependency (`node-addon-api`) is compile-time headers and is not needed at runtime.
*/
async function stageNodePty() {
const target = join(runtimeDir, 'node_modules', 'node-pty');
const require = createRequire(join(root, 'package.json'));
let source;
try {
source = dirname(require.resolve('node-pty/package.json'));
} catch (err) {
throw new Error(
'Cannot resolve node-pty from the builder checkout — run `bun install` first. '
+ `(${err && err.message ? err.message : String(err)})`,
);
}
await rm(target, { recursive: true, force: true });
// EXCLUDE build/ — correctness, not tidiness. node-pty's loader tries
// `build/Release` BEFORE `prebuilds/<platform>-<arch>`, so any compiled artifact
// sitting in the builder's own tree gets copied in and SHADOWS the prebuild we
// actually want.
//
// How a stray build/ appears: node-pty's install script (scripts/prebuild.js)
// exits 0 when `prebuilds/<platform>-<arch>` exists and only falls through to
// `node-gyp rebuild` when it does not. So the release runner (macOS, prebuilds
// present) normally has no build/ — but a Linux dev box does (no linux prebuild),
// `npm_config_build_from_source=true` forces one, and a future node-pty could drop
// a darwin prebuild. Any of those leaves an artifact for this cp to pick up.
//
// Worst case is silent and arch-specific: a macOS builder produces a SINGLE-arch
// Mach-O, which would shadow the OTHER arch's prebuild inside the Universal app —
// terminals dead on half the machines, with nothing failing at build time.
await cp(source, target, {
recursive: true,
dereference: true,
filter: src => !isUnderBuildDir(source, src),
});

// Fail closed, and assert what macOS ACTUALLY loads. Without this the desktop app
// could ship with no terminal support at all and nothing would notice until a user
// opened a session — the exact silent-failure shape this whole change removes.
for (const arch of ['arm64', 'x64']) {
const prebuild = join(target, 'prebuilds', `darwin-${arch}`, 'pty.node');
if (!existsSync(prebuild)) {
throw new Error(`Staged node-pty is missing its darwin-${arch} prebuild (looked for ${prebuild})`);
}
}
// And assert the builder's own platform binary did NOT ride along: its presence
// would shadow the prebuilds above (loader order), which is exactly the failure
// the filter prevents.
const shadowed = join(target, 'build', 'Release', 'pty.node');
if (existsSync(shadowed)) {
throw new Error(`Staged node-pty carries the builder's build/Release/pty.node (${shadowed}); it would shadow the darwin prebuild`);
}
}

/** True when `candidate` is node-pty's `build/` dir (or anything inside it). */
function isUnderBuildDir(pkgRoot, candidate) {
const buildDir = join(pkgRoot, 'build');
return candidate === buildDir || candidate.startsWith(buildDir + sep);
}

function normalizeVersion(value) {
const version = String(value ?? '').trim().replace(/^v/, '');
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version) ? version : null;
Expand Down
25 changes: 25 additions & 0 deletions test/desktop/desktop-bundled-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,29 @@ describe('bundled desktop runtime', () => {
expect(script).toContain("for (const arch of ['arm64', 'x64'])");
expect(script).toContain('Bundled runtime is missing @napi-rs/canvas-darwin-${arch}');
});

it('stages node-pty by copy, without the builder‑platform build/ dir', () => {
const script = readFileSync(resolve(import.meta.dirname, '../../scripts/prepare-desktop-runtime.mjs'), 'utf8');

// node-pty is a devDependency on purpose (its install script would drag node-gyp
// into every `npm i -g`), so `bun install --production` cannot supply it — the
// runtime tree gets it by copy instead. If this call disappears, the desktop app
// ships with no terminal support and nothing else notices.
expect(script).toContain('await stageNodePty();');
// Copying build/ would be actively WRONG, not merely wasteful: node-pty's loader
// tries build/Release BEFORE prebuilds/<platform>-<arch>, so a compiled artifact
// in the builder's own tree shadows the prebuild we want. The release runner
// (macOS, prebuilds present) normally has none, but a Linux dev box does, and
// `npm_config_build_from_source=true` forces one — and in a Universal app a
// single-arch Mach-O would shadow the OTHER arch's prebuild silently.
//
// Assert the filter is WIRED INTO the copy, not merely that the helper exists:
// a first version of this test only checked for the identifier, and deleting the
// `filter:` line left it green (verified by mutation).
expect(script).toMatch(/filter:\s*src\s*=>\s*!isUnderBuildDir\(source,\s*src\)/);
// Both fail-closed assertions must stay: the darwin prebuild must exist, and the
// builder's own binary must NOT have ridden along.
expect(script).toContain('Staged node-pty is missing its darwin-${arch} prebuild');
expect(script).toContain("carries the builder's build/Release/pty.node");
});
});
Loading
Loading