fix(pty): switch to @homebridge/node-pty-prebuilt-multiarch fork

Resolves "Failed to load PTY module" install errors on Linux/macOS by
aliasing node-pty to the homebridge fork, which ships prebuilds for
linux x64/arm64/arm/ia32 across many Node ABIs and uses prebuild-install
for darwin/windows binaries on install.

- Aliased dep so all "node-pty" import specifiers (and vi.mock calls)
  keep working unchanged.
- Removed darwin chmod postinstall hack (fork handles permissions).
- Updated cli/build.ts to dynamically resolve node-pty install root and
  pick prebuilds by ABI for Linux cross-compile; warn-and-skip for
  darwin/windows cross-compile (host-only there, as before).
- Added type shim because the fork's bundled typings declare module
  '@homebridge/node-pty-prebuilt-multiarch', not 'node-pty'.

Verified: pnpm install clean, dashboard typecheck clean, native module
loads and spawns shell via fork, host + linux-x64 cross-compile staging
both produce dist/runtime/<plat>/pty.node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-26 15:00:10 -07:00
parent fa60ada0fc
commit 328b236462
10 changed files with 383 additions and 53 deletions

View File

@@ -170,11 +170,15 @@ dist/
**Important:** When distributing or moving the binary, ensure the `client/` and `runtime/` directories are copied alongside it. Terminal functionality will gracefully degrade (return HTTP 503) if runtime assets are missing — the dashboard will continue to work but terminal sessions won't be available.
**How it works:**
When the dashboard starts from a Bun-compiled binary, it attempts to set up native module resolution so `node-pty` can find its platform-specific `.node` files. This involves:
1. Copying native assets to a temp directory (`/tmp/kb-bunfs-<pid>/kb/prebuilds/<platform>/`)
When the dashboard starts from a Bun-compiled binary, it attempts to set up native module resolution so `@homebridge/node-pty-prebuilt-multiarch` (aliased as `node-pty`) can find its platform-specific `.node` file. This involves:
1. Copying the staged `pty.node` from `runtime/<platform>/` to a temp directory (`/tmp/fn-bunfs-<pid>/fn/prebuilds/<platform>/`)
2. Attempting to create a symlink at `/$bunfs/root` pointing to the temp directory (Unix platforms)
3. If the symlink can't be created (e.g., macOS permissions), pre-loading the native module via `process.dlopen()`
During the build (`bun run build.ts`), native assets are sourced from:
- **Host platform**: `node_modules/node-pty/build/Release/pty.node` (placed by `prebuild-install` at install time)
- **Linux cross-compile targets**: `node_modules/node-pty/prebuilds/linux-<arch>/node.abi<N>.node` (bundled in the fork's npm tarball)
If all resolution methods fail, terminal creation gracefully returns `null`, which the HTTP layer converts to a 503 Service Unavailable response.
**Cross-compilation:** Native assets are staged per-platform during build. When cross-compiling, only the target platform's assets are included. PTY functionality requires running on a platform with matching native assets.

View File

@@ -19,7 +19,8 @@
*/
import { join, dirname } from "node:path";
import { cpSync, mkdirSync, existsSync, rmSync, writeFileSync } from "node:fs";
import { cpSync, mkdirSync, existsSync, rmSync, writeFileSync, readdirSync } from "node:fs";
import { createRequire } from "node:module";
const cliRoot = dirname(new URL(import.meta.url).pathname);
const workspaceRoot = join(cliRoot, "..", "..");
@@ -30,8 +31,56 @@ const runtimeDir = join(outDir, "runtime");
const entryPoint = join(cliRoot, "src", "bin.ts");
// ── Native module asset paths ─────────────────────────────────────────
// node-pty prebuilds location in pnpm workspace
const nodePtyRoot = join(workspaceRoot, "node_modules", ".pnpm", "node-pty@1.1.0", "node_modules", "node-pty");
// Resolve the @homebridge/node-pty-prebuilt-multiarch install root dynamically.
// The package is aliased as "node-pty" in package.json of @fusion/dashboard.
// We must create the require from the dashboard package location so Node resolves
// node-pty via the dashboard's node_modules (where the alias is installed).
const dashboardPkgDir = join(workspaceRoot, "packages", "dashboard");
const _require = createRequire(join(dashboardPkgDir, "package.json"));
let nodePtyRoot: string;
try {
const pkgJsonPath = _require.resolve("node-pty/package.json");
nodePtyRoot = dirname(pkgJsonPath);
console.log(` node-pty resolved to: ${nodePtyRoot}`);
} catch {
// Fallback: check pnpm's shared node_modules
const fallback = join(workspaceRoot, "node_modules", ".pnpm", "node_modules", "node-pty");
if (existsSync(fallback)) {
nodePtyRoot = fallback;
console.log(` node-pty fallback resolved to: ${nodePtyRoot}`);
} else {
// Last resort: rely on pnpm symlink structure
nodePtyRoot = join(dashboardPkgDir, "node_modules", "node-pty");
console.log(` node-pty last-resort resolved to: ${nodePtyRoot}`);
}
}
/**
* Pick the highest ABI .node file from a prebuilds/<plat-arch>/ directory
* that is <= the host Node.js ABI, returning its full path (or null).
* The fork names files like: node.abi115.node, node.abi115.musl.node
* We want the non-musl version (glibc) for cross-compile targets.
*/
function pickHighestAbiNode(prebuildDir: string, targetAbi: number): string | null {
let files: string[];
try {
files = readdirSync(prebuildDir);
} catch {
return null;
}
// Match node.abi<N>.node (non-musl)
const abiRe = /^node\.abi(\d+)\.node$/;
let best: { abi: number; file: string } | null = null;
for (const f of files) {
const m = abiRe.exec(f);
if (!m) continue;
const abi = parseInt(m[1], 10);
if (abi <= targetAbi && (!best || abi > best.abi)) {
best = { abi, file: f };
}
}
return best ? join(prebuildDir, best.file) : null;
}
// ── Supported cross-compilation targets ───────────────────────────────
const SUPPORTED_TARGETS = [
@@ -155,56 +204,101 @@ function ensureClientAssets(): ClientAssetMode {
// ── Copy native terminal assets for a specific target ─────────────────
/**
* Stage node-pty native assets for the given target platform.
* Stage @homebridge/node-pty-prebuilt-multiarch native assets for the given target.
* Assets are placed in dist/runtime/<platform-arch>/ alongside client/.
*
* For each target, we copy:
* - prebuilds/<platform>-<arch>/pty.node (the native binary)
* - prebuilds/<platform>-<arch>/spawn-helper (Unix helper, if exists)
*
* This ensures the standalone binary can find these assets at runtime
* without relying on the original node_modules structure.
*
* The fork ships two layouts:
* - build/Release/pty.node — placed here by `prebuild-install` at install time
* (present on the HOST platform only)
* - prebuilds/linux-<arch>/node.abi<N>.node — bundled inside the npm tarball
* (present for Linux targets on any host)
*
* Strategy per target:
* - Host (no --target flag): use build/Release/pty.node + build/Release/spawn-helper
* - bun-linux-x64/arm64: use prebuilds/linux-<arch>/node.abi<N>.node (highest ≤ host ABI)
* - bun-darwin-x64/arm64: prebuilds not bundled; warn and skip (cross-compile unsupported)
* - bun-windows-x64: prebuilds not bundled; warn and skip
*/
function copyNativeAssets(target?: BunTarget) {
function copyNativeAssets(target?: BunTarget): boolean {
const prebuildName = target ? targetToPrebuildName(target) : hostPrebuildName();
const srcPrebuildDir = join(nodePtyRoot, "prebuilds", prebuildName);
if (!existsSync(srcPrebuildDir)) {
console.warn(` ⚠ No prebuilds found for ${prebuildName} at ${srcPrebuildDir}`);
return false;
}
const destDir = join(runtimeDir, prebuildName);
try {
// Clean and recreate
// Clean and recreate dest
if (existsSync(destDir)) {
rmSync(destDir, { recursive: true, force: true });
}
mkdirSync(destDir, { recursive: true });
// Copy pty.node (required)
const ptyNodeSrc = join(srcPrebuildDir, "pty.node");
const ptyNodeDest = join(destDir, "pty.node");
if (existsSync(ptyNodeSrc)) {
cpSync(ptyNodeSrc, ptyNodeDest);
console.log(`${destDir}/pty.node`);
// ── Determine source pty.node ─────────────────────────────────────
let ptyNodeSrc: string | null = null;
let spawnHelperSrc: string | null = null;
if (!target) {
// HOST build: use the prebuild-install output in build/Release/
const releaseDir = join(nodePtyRoot, "build", "Release");
const candidate = join(releaseDir, "pty.node");
if (existsSync(candidate)) {
ptyNodeSrc = candidate;
const helper = join(releaseDir, "spawn-helper");
if (existsSync(helper)) spawnHelperSrc = helper;
} else {
// Fallback: maybe prebuilds/<plat-arch>/ exists (older fork layout or manually extracted)
const prebuildDir = join(nodePtyRoot, "prebuilds", prebuildName);
const hostAbi = parseInt(process.versions.modules, 10);
ptyNodeSrc = pickHighestAbiNode(prebuildDir, hostAbi);
if (!ptyNodeSrc && existsSync(join(prebuildDir, "pty.node"))) {
// Some layouts ship pty.node directly (shouldn't happen with this fork, but guard)
ptyNodeSrc = join(prebuildDir, "pty.node");
}
const helper = join(prebuildDir, "spawn-helper");
if (existsSync(helper)) spawnHelperSrc = helper;
}
} else if (target.startsWith("bun-linux-")) {
// Linux cross-compile: use the pre-bundled prebuilds/ in the npm tarball
const [, , arch] = target.split("-") as [string, string, string]; // bun-linux-<arch>
// Bun's arm64 → arm64, but armv7 is "arm" in prebuilds
const linuxArch = arch === "arm64" ? "arm64" : arch === "x64" ? "x64" : arch;
const prebuildDir = join(nodePtyRoot, "prebuilds", `linux-${linuxArch}`);
const hostAbi = parseInt(process.versions.modules, 10);
ptyNodeSrc = pickHighestAbiNode(prebuildDir, hostAbi);
if (ptyNodeSrc) {
const helper = join(prebuildDir, "spawn-helper");
if (existsSync(helper)) spawnHelperSrc = helper;
}
} else {
console.warn(` ⚠ pty.node not found for ${prebuildName}`);
// darwin or windows cross-compile: prebuilds are NOT bundled in the tarball.
// They are only present in build/Release/ after prebuild-install runs on that host.
// Warn and skip rather than erroring — the binary will start but terminal won't work.
console.warn(
` WARNING: Cross-compiling for ${target} from ${hostPrebuildName()}. ` +
`The @homebridge/node-pty-prebuilt-multiarch package only bundles Linux prebuilds in the npm tarball. ` +
`Darwin/Windows prebuilds are downloaded by prebuild-install at install time on the target host. ` +
`Terminal functionality will be unavailable in this cross-compiled build.`
);
return false;
}
// Copy spawn-helper if it exists (Unix platforms)
const spawnHelperSrc = join(srcPrebuildDir, "spawn-helper");
if (existsSync(spawnHelperSrc)) {
const spawnHelperDest = join(destDir, "spawn-helper");
cpSync(spawnHelperSrc, spawnHelperDest);
if (!ptyNodeSrc) {
console.warn(` WARNING: No pty.node found for target ${prebuildName}. Terminal will be unavailable.`);
console.warn(` Looked in: ${join(nodePtyRoot, "build", "Release")} and ${join(nodePtyRoot, "prebuilds", prebuildName)}`);
return false;
}
// Copy pty.node (renamed to stable "pty.node" so native-patch.ts can find it)
const ptyNodeDest = join(destDir, "pty.node");
cpSync(ptyNodeSrc, ptyNodeDest);
console.log(`${destDir}/pty.node (from ${ptyNodeSrc})`);
// Copy spawn-helper if available (Unix platforms)
if (spawnHelperSrc) {
cpSync(spawnHelperSrc, join(destDir, "spawn-helper"));
console.log(`${destDir}/spawn-helper`);
}
return true;
} catch (err) {
console.error(` Failed to copy native assets for ${prebuildName}:`, err);
console.error(` ERROR: Failed to copy native assets for ${prebuildName}:`, err);
return false;
}
}

View File

@@ -811,7 +811,7 @@ Plugin management endpoints with multi-project scoping support via `projectId` q
- **Frontend**: React + Vite, TypeScript, xterm.js for terminal emulation, CSS custom properties for theming
- **Backend**: Express server with REST API, badge WebSocket at `/api/ws`, terminal WebSocket at `/api/terminal/ws`, and Server-Sent Events (SSE) for task/log updates
- **Terminal**: node-pty for PTY spawning, WebSocket for bidirectional I/O
- **Terminal**: @homebridge/node-pty-prebuilt-multiarch (aliased as node-pty) for PTY spawning, WebSocket for bidirectional I/O
- **Badge Updates**: `useBadgeWebSocket()` shares a single browser socket and subscribes per visible GitHub-linked task card
- **State Management**: Custom hooks with EventSource for real-time task updates plus a dedicated WebSocket store for badge snapshots
- **Git Integration**: Server-side git command execution with validation

View File

@@ -36,8 +36,7 @@
"build:client": "vite build",
"dev": "pnpm build && pnpm typecheck && pnpm dev:serve",
"dev:serve": "vite dev",
"postinstall": "chmod +x node_modules/.pnpm/node-pty*/node_modules/node-pty/prebuilds/darwin-*/spawn-helper node_modules/.pnpm/node-pty*/node_modules/node-pty/prebuilds/darwin-*/*.node 2>/dev/null || true",
"test": "vitest run --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'",
"test": "vitest run --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'",
"test:build": "vitest run --silent=passed-only --reporter=dot app/__tests__/build-output.test.ts",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
},
@@ -64,7 +63,7 @@
"ioredis": "^5.6.0",
"lucide-react": "^1.7.0",
"multer": "^2.1.1",
"node-pty": "^1.1.0-beta22",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",

View File

@@ -44,8 +44,23 @@ function getNativePrebuildName(): string {
function findInstalledNodePtyNativeDir(): string | null {
try {
const packageJsonPath = require.resolve("node-pty/package.json");
const nativeDir = join(dirname(packageJsonPath), "prebuilds", getNativePrebuildName());
return fs.existsSync(join(nativeDir, "pty.node")) ? nativeDir : null;
const pkgRoot = dirname(packageJsonPath);
// @homebridge/node-pty-prebuilt-multiarch (aliased as node-pty) places the binary
// in build/Release/pty.node after prebuild-install runs at install time.
// Prefer this location as it is the fork's standard output path.
const releaseDir = join(pkgRoot, "build", "Release");
if (fs.existsSync(join(releaseDir, "pty.node"))) {
return releaseDir;
}
// Fallback: check the old prebuilds/<plat-arch>/ layout (upstream node-pty style).
const prebuildDir = join(pkgRoot, "prebuilds", getNativePrebuildName());
if (fs.existsSync(join(prebuildDir, "pty.node"))) {
return prebuildDir;
}
return null;
} catch {
return null;
}

View File

@@ -0,0 +1,80 @@
/**
* Type shim for the `node-pty` import specifier.
*
* The runtime package is @homebridge/node-pty-prebuilt-multiarch, aliased as
* "node-pty" in package.json. Its bundled typings use `declare module
* '@homebridge/node-pty-prebuilt-multiarch'` which TypeScript cannot resolve
* via the npm alias alone. This shim re-declares the module under the `node-pty`
* specifier so all source imports of `"node-pty"` resolve correctly.
*
* API surface matches node-pty 0.10.x / @homebridge/node-pty-prebuilt-multiarch 0.13.x.
*/
declare module "node-pty" {
/**
* An object that can be disposed via a dispose function.
*/
export interface IDisposable {
dispose(): void;
}
/**
* An event that can be listened to.
* @returns an IDisposable to stop listening.
*/
export interface IEvent<T> {
(listener: (e: T) => unknown): IDisposable;
}
export interface IBasePtyForkOptions {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: { [key: string]: string | undefined };
encoding?: string | null;
handleFlowControl?: boolean;
flowControlPause?: string;
flowControlResume?: string;
}
export interface IPtyForkOptions extends IBasePtyForkOptions {
uid?: number;
gid?: number;
}
export interface IWindowsPtyForkOptions extends IBasePtyForkOptions {
useConpty?: boolean;
useConptyDll?: boolean;
conptyInheritCursor?: boolean;
}
/**
* An interface representing a pseudoterminal.
*/
export interface IPty {
readonly pid: number;
readonly cols: number;
readonly rows: number;
readonly process: string;
handleFlowControl: boolean;
readonly onData: IEvent<string>;
readonly onExit: IEvent<{ exitCode: number; signal?: number }>;
resize(columns: number, rows: number): void;
on(event: "data", listener: (data: string) => void): void;
on(event: "exit", listener: (exitCode: number, signal?: number) => void): void;
clear(): void;
write(data: string): void;
kill(signal?: string): void;
pause(): void;
resume(): void;
}
/**
* Forks a process as a pseudoterminal.
*/
export function spawn(
file: string,
args: string[] | string,
options: IPtyForkOptions | IWindowsPtyForkOptions,
): IPty;
}

View File

@@ -6,7 +6,10 @@
"moduleResolution": "bundler",
"module": "ESNext",
"noEmit": true,
"types": ["vitest/globals", "@testing-library/jest-dom", "node", "vite/client"]
"types": ["vitest/globals", "@testing-library/jest-dom", "node", "vite/client"],
"paths": {
"node-pty": ["./src/types/node-pty/index.d.ts"]
}
},
"include": ["app/**/*"],
"exclude": ["app/**/*.test.ts", "app/**/*.test.tsx", "app/**/__tests__/**/*"]

View File

@@ -6,7 +6,8 @@
"jsx": "react-jsx",
"types": ["node", "vitest/globals", "@testing-library/jest-dom"],
"paths": {
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"]
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"],
"node-pty": ["./src/types/node-pty/index.d.ts"]
}
},
"include": ["src/**/*"],