Archived
fix(cli): diagnose macOS node-pty spawn-helper permissions
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user