From 34e657dd2bc75cc58917f743557af2594caa5f94 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 25 May 2026 14:03:43 +0200 Subject: [PATCH] fix(cli): diagnose macOS node-pty spawn-helper permissions --- .changeset/gentle-panthers-doctor.md | 5 + src/cli.ts | 10 +- .../diagnostics/nodePtySpawnHelper.test.ts | 97 ++++++++++ src/server/diagnostics/nodePtySpawnHelper.ts | 174 ++++++++++++++++++ 4 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 .changeset/gentle-panthers-doctor.md create mode 100644 src/server/diagnostics/nodePtySpawnHelper.test.ts create mode 100644 src/server/diagnostics/nodePtySpawnHelper.ts diff --git a/.changeset/gentle-panthers-doctor.md b/.changeset/gentle-panthers-doctor.md new file mode 100644 index 0000000..48b6f6e --- /dev/null +++ b/.changeset/gentle-panthers-doctor.md @@ -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. diff --git a/src/cli.ts b/src/cli.ts index e54ad03..370e1f5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 { diff --git a/src/server/diagnostics/nodePtySpawnHelper.test.ts b/src/server/diagnostics/nodePtySpawnHelper.test.ts new file mode 100644 index 0000000..04ae203 --- /dev/null +++ b/src/server/diagnostics/nodePtySpawnHelper.test.ts @@ -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 }; + } +}); diff --git a/src/server/diagnostics/nodePtySpawnHelper.ts b/src/server/diagnostics/nodePtySpawnHelper.ts new file mode 100644 index 0000000..0370c73 --- /dev/null +++ b/src/server/diagnostics/nodePtySpawnHelper.ts @@ -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): 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); +}