Files
Codeman/scripts/fix-node-pty.mjs
Codeman maintainer 23f258a85d chore: version packages
Release 1.9.8 (aicodeman) and 0.1.8 (xterm-zerolag-input).

Fixes macOS session start (`posix_spawnp failed.`, issues #6 and #204):
node-pty ships its macOS spawn-helper as mode 0644 and macOS launches every
PTY through it. `scripts/fix-node-pty.mjs` (npm run fix:node-pty) chmods every
helper, prebuilds/ included, then verifies by really opening a PTY; the blind
Node-22+ rebuild is gone. `spawnPtyWithHelperRepair()` self-heals an already
broken install on the first failed spawn.

Adds the phone home screen (session overview under 430px, per-device
`mobileOverviewEnabled`, default ON) and a guided Tailscale path in
install.sh, plus `install.sh tailscale` to retrofit it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:02:46 +02:00

262 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* @fileoverview Repairs node-pty's macOS `spawn-helper` and verifies that a PTY
* can really be spawned. Called by `scripts/postinstall.js` on every install and
* exposed as `npm run fix:node-pty` for repairing an install after the fact.
*
* Why this exists (issues #6 and #204):
*
* node-pty@1.1.0 publishes its macOS prebuilt helper as
* `prebuilds/darwin-<arch>/spawn-helper` with mode 0644, i.e. no execute bit.
* On macOS node-pty launches every PTY through that helper with posix_spawnp,
* which then fails EACCES and surfaces as `Error: posix_spawnp failed.` on every
* session start.
*
* It is macOS-exclusive twice over: `spawn-helper` is an `OS=="mac"` gyp target,
* and pty.cc only spawns it under `#if defined(__APPLE__)`. node-pty ships
* prebuilds for darwin and win32 only, so Linux always compiles from source
* (which produces an executable helper) and never sees the bug.
*
* The repair is a chmod, NOT a rebuild: the prebuilt binary itself is fine, and
* requiring a from-source rebuild would make every macOS install depend on Xcode
* command line tools. A rebuild is attempted only when a chmod plus a real spawn
* probe still can't get a working PTY, and the prebuilds tree is backed up first
* so a failed rebuild can never leave the install worse than it started.
*/
import { chmodSync, cpSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
/** Errors that mean "the native module or its helper is unusable", i.e. worth a rebuild. */
const NATIVE_FAILURE_PATTERN = /posix_spawnp|spawn-helper|Failed to load native module|Cannot find module/i;
/**
* Locates the installed node-pty package directory.
*
* @returns {string|null} Absolute path to the package root, or null if not installed.
*/
export function findNodePtyDir() {
// package.json first: node-pty declares no "exports" map, so the subpath resolves,
// and it lands on the package root directly. require.resolve('node-pty') would give
// <pkg>/lib/index.js, which is one directory deeper than callers expect.
try {
return dirname(require.resolve('node-pty/package.json'));
} catch {
/* fall through */
}
try {
return join(dirname(require.resolve('node-pty')), '..');
} catch {
return null;
}
}
/**
* Lists every `spawn-helper` shipped in a node-pty install.
*
* node-pty's own loader (lib/utils.js) checks `build/Release`, `build/Debug` and
* then `prebuilds/<platform>-<arch>`, and takes the helper from whichever
* directory the native module loaded out of, so all of them must be executable,
* not just the one this machine happens to use today.
*
* @param {string} ptyDir Absolute path to the node-pty package root.
* @returns {string[]} Absolute paths of the helpers that exist on disk.
*/
export function listSpawnHelpers(ptyDir) {
const dirs = [join(ptyDir, 'build', 'Release'), join(ptyDir, 'build', 'Debug')];
const prebuilds = join(ptyDir, 'prebuilds');
if (existsSync(prebuilds)) {
try {
for (const entry of readdirSync(prebuilds, { withFileTypes: true })) {
if (entry.isDirectory()) dirs.push(join(prebuilds, entry.name));
}
} catch {
/* unreadable prebuilds dir: nothing to repair there */
}
}
return dirs.map((d) => join(d, 'spawn-helper')).filter((p) => existsSync(p));
}
/**
* Adds the execute bit to every `spawn-helper` that is missing it.
*
* @param {string} ptyDir Absolute path to the node-pty package root.
* @returns {{ repaired: string[], failed: Array<{ path: string, error: string }> }}
*/
export function repairSpawnHelpers(ptyDir) {
const repaired = [];
const failed = [];
for (const helper of listSpawnHelpers(ptyDir)) {
try {
const mode = statSync(helper).mode & 0o777;
if ((mode & 0o111) === 0o111) continue; // already executable by all
chmodSync(helper, mode | 0o755);
repaired.push(helper);
} catch (err) {
failed.push({ path: helper, error: err instanceof Error ? err.message : String(err) });
}
}
return { repaired, failed };
}
/**
* Proves node-pty works by actually opening a PTY, which is the only check that
* exercises the spawn-helper path that breaks. A `require` alone would pass on a
* broken install, because the helper is only touched at spawn time.
*
* @param {string} ptyDir Absolute path to the node-pty package root.
* @returns {{ ok: boolean, error?: string, nativeFailure?: boolean }}
*/
export function verifyPtySpawn(ptyDir) {
let child;
try {
const pty = require(ptyDir); // directory require → node-pty's "main" (lib/index.js)
const file = process.platform === 'win32' ? process.env.COMSPEC || 'cmd.exe' : '/bin/echo';
const args = process.platform === 'win32' ? ['/c', 'exit'] : ['codeman-node-pty-check'];
child = pty.spawn(file, args, {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: tmpdir(),
env: process.env,
});
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, error: message, nativeFailure: NATIVE_FAILURE_PATTERN.test(message) };
} finally {
try {
child?.kill();
} catch {
/* the probe child exits on its own anyway */
}
}
}
/**
* Rebuilds node-pty from source, preserving the prebuilds tree across a failure.
*
* node-pty's install script deletes `prebuilds/` as soon as
* `npm_config_build_from_source` is set and only then shells out to node-gyp, so
* a machine without a compiler toolchain would otherwise be left with neither a
* prebuilt nor a compiled binary.
*
* @param {string} ptyDir Absolute path to the node-pty package root.
* @param {string} cwd Directory to run npm from (the package root that owns node_modules).
* @returns {{ ok: boolean, error?: string }}
*/
function rebuildFromSource(ptyDir, cwd) {
const prebuilds = join(ptyDir, 'prebuilds');
const backup = join(ptyDir, '.prebuilds-codeman-backup');
let backedUp = false;
if (existsSync(prebuilds)) {
try {
rmSync(backup, { recursive: true, force: true });
cpSync(prebuilds, backup, { recursive: true });
backedUp = true;
} catch {
/* best effort: proceed without a safety net rather than skip the repair */
}
}
try {
execSync('npm rebuild node-pty --build-from-source', { cwd, stdio: 'pipe', timeout: 300000 });
return { ok: true };
} catch (err) {
if (backedUp && !existsSync(prebuilds)) {
try {
cpSync(backup, prebuilds, { recursive: true });
} catch {
/* nothing further we can do */
}
}
return { ok: false, error: err instanceof Error ? err.message : String(err) };
} finally {
rmSync(backup, { recursive: true, force: true });
}
}
/**
* Full repair flow: chmod, verify, and only rebuild if a working PTY still can't
* be opened.
*
* @param {object} [options]
* @param {(line: string) => void} [options.log] Progress sink (default: silent).
* @param {(line: string) => void} [options.warn] Warning sink (default: same as log).
* @param {boolean} [options.allowRebuild] Permit a from-source rebuild (default: true).
* @returns {Promise<{ ok: boolean, repaired: string[], rebuilt: boolean, reason?: string }>}
*/
export async function fixNodePty(options = {}) {
const log = options.log ?? (() => {});
const warn = options.warn ?? log;
const allowRebuild = options.allowRebuild ?? true;
const ptyDir = findNodePtyDir();
if (!ptyDir) {
return { ok: false, repaired: [], rebuilt: false, reason: 'node-pty is not installed' };
}
const { repaired, failed } = repairSpawnHelpers(ptyDir);
for (const f of failed) warn(`could not chmod ${f.path}: ${f.error}`);
if (repaired.length > 0) {
log(`made node-pty spawn-helper executable (${repaired.length} file${repaired.length === 1 ? '' : 's'})`);
}
const first = verifyPtySpawn(ptyDir);
if (first.ok) return { ok: true, repaired, rebuilt: false };
if (!allowRebuild || !first.nativeFailure) {
return { ok: false, repaired, rebuilt: false, reason: first.error };
}
warn(`node-pty could not open a PTY (${first.error}), rebuilding from source...`);
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const rebuild = rebuildFromSource(ptyDir, projectRoot);
if (!rebuild.ok) {
return { ok: false, repaired, rebuilt: false, reason: `rebuild failed: ${rebuild.error}` };
}
const after = repairSpawnHelpers(ptyDir);
repaired.push(...after.repaired);
const second = verifyPtySpawn(ptyDir);
return second.ok
? { ok: true, repaired, rebuilt: true }
: { ok: false, repaired, rebuilt: true, reason: second.error };
}
// ---------------------------------------------------------------------------
// CLI: node scripts/fix-node-pty.mjs [--quiet]
// ---------------------------------------------------------------------------
const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isDirectRun) {
const quiet = process.argv.includes('--quiet');
const say = (line) => {
if (!quiet) console.log(line);
};
const result = await fixNodePty({ log: say, warn: (line) => console.warn(line) });
if (result.ok) {
say(result.repaired.length > 0 || result.rebuilt ? 'node-pty repaired, PTY spawning works' : 'node-pty is healthy');
process.exit(0);
}
console.error(`node-pty is not usable: ${result.reason}`);
console.error('Try: cd node_modules/node-pty && npx node-gyp rebuild');
process.exit(1);
}