fix(cli): diagnose macOS node-pty spawn-helper permissions

This commit is contained in:
Federico Jaramillo Martinez
2026-05-25 14:03:43 +02:00
parent 679008d132
commit 34e657dd2b
4 changed files with 285 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add a `pi-web doctor` diagnostic for the upstream macOS node-pty `spawn-helper` permission issue, including the workaround and tracking links.
+9 -1
View File
@@ -6,6 +6,7 @@ import { basename, dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { defaultPiWebConfigPath, examplePiWebConfig } from "./config.js";
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
const serviceDir = join(homedir(), ".config", "systemd", "user");
const sessiondServiceName = "pi-web-sessiond.service";
@@ -394,6 +395,7 @@ function printPathSetupAdvice(): void {
function doctor(): void {
console.log(`Service shell: ${describeServiceShell()}`);
const ok = runChecks(doctorChecks());
const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
const linger = isLingerEnabled();
if (linger === true) {
@@ -410,8 +412,14 @@ function doctor(): void {
console.log("\nIf a command works in your terminal but fails here, make sure your service shell login files set PATH the same way.");
console.log("If a bundled entrypoint is not accessible, reinstall or update the PI WEB package.");
printPathSetupAdvice();
process.exitCode = 1;
}
if (!ok || !nodePtySpawnHelperOk) process.exitCode = 1;
}
function printNodePtyDarwinSpawnHelperCheck(): boolean {
const result = formatNodePtyDarwinSpawnHelperCheck(checkNodePtyDarwinSpawnHelper());
for (const line of result.lines) console.log(line);
return result.ok;
}
function help(): void {
@@ -0,0 +1,97 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck, PI_WEB_SPAWN_HELPER_ISSUE_URL } from "./nodePtySpawnHelper.js";
const allowAccess = (): void => undefined;
describe("node-pty macOS spawn-helper diagnostics", () => {
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
});
it("skips the check outside macOS", () => {
const check = checkNodePtyDarwinSpawnHelper({ platform: "linux" });
expect(check).toEqual({ status: "skipped", reason: "not-macos" });
expect(formatNodePtyDarwinSpawnHelperCheck(check)).toEqual({ ok: true, lines: [] });
});
it("reports a proposed chmod workaround for non-executable helpers", async () => {
const fixture = await createNodePtyFixture();
const check = checkNodePtyDarwinSpawnHelper({
platform: "darwin",
arch: "arm64",
nodePtyPackageJsonPath: fixture.packageJsonPath,
access: () => { throw new Error("not executable"); },
});
expect(check).toMatchObject({
status: "spawn-helper-not-executable",
helperPath: fixture.helperPath,
nodePtyRoot: fixture.root,
fixCommand: `chmod +x '${fixture.helperPath}'`,
});
const formatted = formatNodePtyDarwinSpawnHelperCheck(check);
expect(formatted.ok).toBe(false);
expect(formatted.lines).toContain(` PI WEB tracking issue: ${PI_WEB_SPAWN_HELPER_ISSUE_URL}`);
expect(formatted.lines).toContain(` chmod +x '${fixture.helperPath}'`);
});
it("passes when the selected helper is executable", async () => {
const fixture = await createNodePtyFixture();
const check = checkNodePtyDarwinSpawnHelper({
platform: "darwin",
arch: "arm64",
nodePtyPackageJsonPath: fixture.packageJsonPath,
access: allowAccess,
});
expect(check).toMatchObject({ status: "ok", helperPath: fixture.helperPath, nodePtyRoot: fixture.root });
expect(formatNodePtyDarwinSpawnHelperCheck(check)).toEqual({
ok: true,
lines: ["✓ node-pty macOS spawn-helper executable", ` ${fixture.helperPath}`],
});
});
it("checks the helper next to node-pty's selected native module", async () => {
const fixture = await createNodePtyFixture();
const buildDir = join(fixture.root, "build", "Release");
await mkdir(buildDir, { recursive: true });
await writeFile(join(buildDir, "pty.node"), "");
const buildHelperPath = join(buildDir, "spawn-helper");
await writeFile(buildHelperPath, "");
const check = checkNodePtyDarwinSpawnHelper({
platform: "darwin",
arch: "arm64",
nodePtyPackageJsonPath: fixture.packageJsonPath,
access: allowAccess,
});
expect(check).toMatchObject({ status: "ok", helperPath: buildHelperPath });
});
async function createNodePtyFixture(): Promise<{ root: string; packageJsonPath: string; helperPath: string }> {
const root = await mkdtemp(join(tmpdir(), "pi-web-node-pty-"));
tempRoots.push(root);
const packageJsonPath = join(root, "package.json");
const prebuildDir = join(root, "prebuilds", "darwin-arm64");
await mkdir(prebuildDir, { recursive: true });
await writeFile(packageJsonPath, "{}");
await writeFile(join(prebuildDir, "pty.node"), "");
const helperPath = join(prebuildDir, "spawn-helper");
await writeFile(helperPath, "");
return { root, packageJsonPath, helperPath };
}
});
@@ -0,0 +1,174 @@
import { accessSync, constants, existsSync, statSync, type Stats } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
export const PI_WEB_SPAWN_HELPER_ISSUE_URL = "https://github.com/jmfederico/pi-web/issues/4";
export const NODE_PTY_SPAWN_HELPER_UPSTREAM_ISSUE_URL = "https://github.com/microsoft/node-pty/issues/850";
const doctorLabel = "node-pty macOS spawn-helper executable";
const requireFromHere = createRequire(import.meta.url);
type FileExists = (path: string) => boolean;
type FileStat = (path: string) => Stats;
type FileAccess = (path: string, mode: number) => void;
export interface NodePtyDarwinSpawnHelperCheckOptions {
platform?: NodeJS.Platform;
arch?: string;
nodePtyPackageJsonPath?: string;
resolveNodePtyPackageJson?: () => string;
exists?: FileExists;
stat?: FileStat;
access?: FileAccess;
}
export type NodePtyDarwinSpawnHelperCheck =
| { status: "skipped"; reason: "not-macos" }
| { status: "ok"; helperPath: string; nodePtyRoot: string }
| { status: "node-pty-not-found"; message: string }
| { status: "native-module-not-found"; nodePtyRoot: string; expectedHelperPath: string }
| { status: "spawn-helper-missing"; helperPath: string; nodePtyRoot: string }
| { status: "spawn-helper-not-file"; helperPath: string; nodePtyRoot: string }
| { status: "spawn-helper-stat-error"; helperPath: string; nodePtyRoot: string; message: string }
| { status: "spawn-helper-not-executable"; helperPath: string; nodePtyRoot: string; fixCommand: string };
export interface FormattedNodePtyDarwinSpawnHelperCheck {
ok: boolean;
lines: string[];
}
export function checkNodePtyDarwinSpawnHelper(options: NodePtyDarwinSpawnHelperCheckOptions = {}): NodePtyDarwinSpawnHelperCheck {
const platform = options.platform ?? process.platform;
if (platform !== "darwin") return { status: "skipped", reason: "not-macos" };
const arch = options.arch ?? process.arch;
const exists = options.exists ?? existsSync;
const stat = options.stat ?? statSync;
const access = options.access ?? accessSync;
let nodePtyPackageJsonPath: string;
try {
nodePtyPackageJsonPath = options.nodePtyPackageJsonPath ?? (options.resolveNodePtyPackageJson ?? resolveNodePtyPackageJson)();
} catch (error) {
return { status: "node-pty-not-found", message: errorMessage(error) };
}
const nodePtyRoot = dirname(nodePtyPackageJsonPath);
const nativeDir = findNodePtyNativeDir(nodePtyRoot, platform, arch, exists);
if (nativeDir === undefined) {
return {
status: "native-module-not-found",
nodePtyRoot,
expectedHelperPath: join(nodePtyRoot, "prebuilds", `${platform}-${arch}`, "spawn-helper"),
};
}
const helperPath = join(nativeDir, "spawn-helper");
try {
if (!stat(helperPath).isFile()) return { status: "spawn-helper-not-file", helperPath, nodePtyRoot };
} catch (error) {
if (isFileNotFoundError(error)) return { status: "spawn-helper-missing", helperPath, nodePtyRoot };
return { status: "spawn-helper-stat-error", helperPath, nodePtyRoot, message: errorMessage(error) };
}
try {
access(helperPath, constants.X_OK);
return { status: "ok", helperPath, nodePtyRoot };
} catch {
return { status: "spawn-helper-not-executable", helperPath, nodePtyRoot, fixCommand: chmodFixCommand(helperPath) };
}
}
export function formatNodePtyDarwinSpawnHelperCheck(check: NodePtyDarwinSpawnHelperCheck): FormattedNodePtyDarwinSpawnHelperCheck {
if (check.status === "skipped") return { ok: true, lines: [] };
if (check.status === "ok") {
return {
ok: true,
lines: [`${doctorLabel}`, ` ${check.helperPath}`],
};
}
if (check.status === "spawn-helper-not-executable") {
return {
ok: false,
lines: [
`${doctorLabel}`,
` ${check.helperPath} exists but is not executable.`,
` Known upstream node-pty packaging issue: ${NODE_PTY_SPAWN_HELPER_UPSTREAM_ISSUE_URL}`,
` PI WEB tracking issue: ${PI_WEB_SPAWN_HELPER_ISSUE_URL}`,
" Proposed workaround:",
` ${check.fixCommand}`,
" Then restart the pi-web-sessiond/pi-web-server processes.",
],
};
}
return {
ok: false,
lines: [`${doctorLabel}`, ...failureDetails(check)],
};
}
function resolveNodePtyPackageJson(): string {
return requireFromHere.resolve("node-pty/package.json");
}
function findNodePtyNativeDir(nodePtyRoot: string, platform: NodeJS.Platform, arch: string, exists: FileExists): string | undefined {
for (const dir of nodePtyNativeDirs(nodePtyRoot, platform, arch)) {
if (exists(join(dir, "pty.node"))) return dir;
}
return undefined;
}
function nodePtyNativeDirs(nodePtyRoot: string, platform: NodeJS.Platform, arch: string): string[] {
const dirs = ["build/Release", "build/Debug", `prebuilds/${platform}-${arch}`];
return dirs.flatMap((dir) => [join(nodePtyRoot, dir), join(nodePtyRoot, "lib", dir)]);
}
function failureDetails(check: Exclude<NodePtyDarwinSpawnHelperCheck, { status: "ok" | "skipped" | "spawn-helper-not-executable" }>): string[] {
if (check.status === "node-pty-not-found") {
return [
` Could not resolve node-pty from PI WEB: ${check.message}`,
" Reinstall or update PI WEB, then run `pi-web doctor` again.",
];
}
if (check.status === "native-module-not-found") {
return [
` Could not find node-pty's native pty.node module under ${check.nodePtyRoot}.`,
` Expected macOS helper location: ${check.expectedHelperPath}`,
" Reinstall or update PI WEB, then run `pi-web doctor` again.",
];
}
if (check.status === "spawn-helper-missing") {
return [
` Expected helper is missing: ${check.helperPath}`,
" Reinstall or update PI WEB, then run `pi-web doctor` again.",
];
}
if (check.status === "spawn-helper-not-file") {
return [
` Expected helper is not a regular file: ${check.helperPath}`,
" Reinstall or update PI WEB, then run `pi-web doctor` again.",
];
}
return [
` Could not inspect ${check.helperPath}: ${check.message}`,
" Check the file permissions, then run `pi-web doctor` again.",
];
}
function chmodFixCommand(path: string): string {
return `chmod +x ${shellSingleQuote(path)}`;
}
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function isFileNotFoundError(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}