fix(install): allow node-pty scripts with npm 12

This commit is contained in:
Federico Jaramillo Martinez
2026-07-20 11:57:39 +02:00
parent 4ca4a1d096
commit b48b147b5b
19 changed files with 396 additions and 20 deletions
+18 -5
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
import { defaultPiWebConfigPath, defaultPiWebDataDir, effectivePiWebConfig, examplePiWebConfig } from "./config.js";
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
import { checkNodePtyNativeModule, formatNodePtyNativeModuleCheck } from "./server/diagnostics/nodePtyNativeModule.js";
import {
installNativeServiceCandidate,
nativeServiceInstallFailureNeedsPathAdvice,
@@ -664,6 +665,9 @@ async function install(args: string[]): Promise<void> {
console.log(`Running PI WEB ${options.mode} install preflight checks...`);
console.log(`Service backend: ${backend.label}`);
console.log(`Service shell: ${describeServiceShell()}`);
if (!printNodePtyNativeModuleCheck()) {
throw new Error("Install preflight checks failed without changing config or services. Fix the failure above, then run `pi-web doctor` for more detail.");
}
const result = await installNativeServiceCandidate(candidate, {
probe: createNativeServiceAuthoritativeProbe(),
fileExists: regularFileExists,
@@ -967,9 +971,9 @@ function printPathSetupAdvice(shell: NativeServiceShell = detectServiceShell()):
export function doctorExitCode(
generalReadinessOk: boolean,
nativeServicePlanOk: boolean,
nodePtySpawnHelperOk: boolean,
nodePtyRuntimeOk: boolean,
): 0 | 1 {
return generalReadinessOk && nativeServicePlanOk && nodePtySpawnHelperOk ? 0 : 1;
return generalReadinessOk && nativeServicePlanOk && nodePtyRuntimeOk ? 0 : 1;
}
async function doctor(): Promise<void> {
@@ -986,7 +990,10 @@ async function doctor(): Promise<void> {
console.log("\nGeneral login-shell readiness (separate from native-service requirements):");
const generalReadinessOk = runChecks(generalDoctorChecks());
printOptionalDoctorChecks();
const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
console.log("\nNative terminal runtime readiness:");
const nodePtyNativeModuleOk = printNodePtyNativeModuleCheck();
const nodePtySpawnHelperOk = nodePtyNativeModuleOk ? printNodePtyDarwinSpawnHelperCheck() : true;
let nativeServiceReport: NativeServiceDoctorReport | null = null;
if (backend !== undefined) {
@@ -1025,7 +1032,13 @@ async function doctor(): Promise<void> {
console.log(`\n${manualRunAdvice()}`);
}
if (doctorExitCode(generalReadinessOk, nativeServicePlanOk, nodePtySpawnHelperOk) !== 0) process.exitCode = 1;
if (doctorExitCode(generalReadinessOk, nativeServicePlanOk, nodePtyNativeModuleOk && nodePtySpawnHelperOk) !== 0) process.exitCode = 1;
}
function printNodePtyNativeModuleCheck(): boolean {
const result = formatNodePtyNativeModuleCheck(checkNodePtyNativeModule());
for (const line of result.lines) console.log(line);
return result.ok;
}
function printNodePtyDarwinSpawnHelperCheck(): boolean {
@@ -1049,7 +1062,7 @@ Usage:
pi-web version
Recommended install:
npm install -g @jmfederico/pi-web
npm install -g @jmfederico/pi-web --allow-scripts=node-pty
pi-web install
Development service install from a checkout:
+71
View File
@@ -0,0 +1,71 @@
import { execFile } from "node:child_process";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const installerPath = join(repoRoot, "install.sh");
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
});
describe.skipIf(process.platform === "win32")("global install script", () => {
it("scopes script approval to node-pty before installing services", async () => {
const fixture = await createFixture();
await execUtf8("sh", [installerPath], fixture.env);
expect((await readFile(fixture.npmArgsPath, "utf8")).trim().split("\n")).toEqual([
"install",
"-g",
"@jmfederico/pi-web",
"--allow-scripts=node-pty",
]);
expect((await readFile(fixture.piWebArgsPath, "utf8")).trim().split("\n")).toEqual(["install"]);
});
});
async function createFixture(): Promise<{
env: NodeJS.ProcessEnv;
npmArgsPath: string;
piWebArgsPath: string;
}> {
const root = await mkdtemp(join(tmpdir(), "pi-web-install-script-"));
tempRoots.push(root);
const npmArgsPath = join(root, "npm-args");
const piWebArgsPath = join(root, "pi-web-args");
const npmPath = join(root, "npm");
const piWebPath = join(root, "pi-web");
await Promise.all([
writeFile(npmPath, "#!/usr/bin/env sh\nprintf '%s\\n' \"$@\" > \"$FAKE_NPM_ARGS\"\n"),
writeFile(piWebPath, "#!/usr/bin/env sh\nprintf '%s\\n' \"$@\" > \"$FAKE_PI_WEB_ARGS\"\n"),
]);
await Promise.all([chmod(npmPath, 0o755), chmod(piWebPath, 0o755)]);
return {
env: {
...process.env,
PATH: `${root}:${process.env["PATH"] ?? ""}`,
FAKE_NPM_ARGS: npmArgsPath,
FAKE_PI_WEB_ARGS: piWebArgsPath,
},
npmArgsPath,
piWebArgsPath,
};
}
function execUtf8(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<string> {
return new Promise((resolvePromise, reject) => {
execFile(file, args, { env, encoding: "utf8" }, (error, stdout) => {
if (error !== null) {
reject(error instanceof Error ? error : new Error("Command failed"));
return;
}
resolvePromise(stdout);
});
});
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
checkNodePtyNativeModule,
formatNodePtyNativeModuleCheck,
NODE_PTY_GLOBAL_REINSTALL_COMMAND,
} from "./nodePtyNativeModule.js";
describe("node-pty native module diagnostics", () => {
it("passes when node-pty loads", () => {
const check = checkNodePtyNativeModule({ load: () => ({ spawn: () => undefined }) });
expect(check).toEqual({ status: "ok" });
expect(formatNodePtyNativeModuleCheck(check)).toEqual({
ok: true,
lines: ["✓ node-pty native module loadable"],
});
});
it("reports the scoped global reinstall command when node-pty cannot load", () => {
const check = checkNodePtyNativeModule({
load: () => { throw new Error("Failed to load native module: pty.node\nchecked build/Release"); },
});
expect(check).toEqual({
status: "load-failed",
message: "Failed to load native module: pty.node checked build/Release",
});
const formatted = formatNodePtyNativeModuleCheck(check);
expect(formatted.ok).toBe(false);
expect(formatted.lines).toContain(` ${NODE_PTY_GLOBAL_REINSTALL_COMMAND}`);
expect(formatted.lines).toContain(" Then run `pi-web doctor` again.");
expect(formatted.lines.join("\n")).not.toContain("dangerously-allow-all-scripts");
});
});
@@ -0,0 +1,54 @@
import { createRequire } from "node:module";
export const NODE_PTY_GLOBAL_REINSTALL_COMMAND = "npm install -g @jmfederico/pi-web --allow-scripts=node-pty";
const doctorLabel = "node-pty native module loadable";
const requireFromHere = createRequire(import.meta.url);
type LoadNodePty = () => unknown;
export interface NodePtyNativeModuleCheckOptions {
load?: LoadNodePty;
}
export type NodePtyNativeModuleCheck =
| { status: "ok" }
| { status: "load-failed"; message: string };
export interface FormattedNodePtyNativeModuleCheck {
ok: boolean;
lines: string[];
}
export function checkNodePtyNativeModule(options: NodePtyNativeModuleCheckOptions = {}): NodePtyNativeModuleCheck {
try {
(options.load ?? loadNodePty)();
return { status: "ok" };
} catch (error) {
return { status: "load-failed", message: errorMessage(error) };
}
}
export function formatNodePtyNativeModuleCheck(check: NodePtyNativeModuleCheck): FormattedNodePtyNativeModuleCheck {
if (check.status === "ok") return { ok: true, lines: [`${doctorLabel}`] };
return {
ok: false,
lines: [
`${doctorLabel}`,
` Could not load node-pty: ${check.message}`,
" npm may have skipped node-pty's required install script.",
" For a global npm installation, reinstall PI WEB with:",
` ${NODE_PTY_GLOBAL_REINSTALL_COMMAND}`,
" Then run `pi-web doctor` again.",
],
};
}
function loadNodePty(): unknown {
return requireFromHere("node-pty");
}
function errorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.replaceAll(/\s+/g, " ").trim();
}
+1
View File
@@ -63,6 +63,7 @@ describe("Docker command assets", () => {
expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec");
expect(dockerfile).toContain("COPY internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base");
expect(dockerfile).toContain("--include=peer");
expect(dockerfile).toContain('"@jmfederico/pi-web@${PI_WEB_VERSION}" --allow-scripts=node-pty');
expect(dockerfile).toContain('peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"');
expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@");
expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker");
+20
View File
@@ -215,6 +215,26 @@ describe("PI WEB status", () => {
expect(updateCommand).toBe("PI_CODING_AGENT_DIR='/tmp/profile'\\''s/state' '/tmp/agent'\\''s/pi' update 'npm:@jmfederico/pi-web' && pi-web restart");
});
it("scopes node-pty script approval in npm-global update commands", async () => {
const updateCommand = await updateCommandFor(
{ kind: "npm-global", path: "/opt/npm/@jmfederico/pi-web" },
"pi-web restart",
{ activeAgentProfile: undefined, hasCommand: () => Promise.resolve(true) },
);
expect(updateCommand).toBe("npm install -g @jmfederico/pi-web --allow-scripts=node-pty && pi-web restart");
});
it("suppresses npm-global update commands when npm is unavailable", async () => {
const updateCommand = await updateCommandFor(
{ kind: "npm-global", path: "/opt/npm/@jmfederico/pi-web" },
"pi-web restart",
{ activeAgentProfile: undefined, hasCommand: () => Promise.resolve(false) },
);
expect(updateCommand).toBeUndefined();
});
it.each([
activeProfile("a", "acme-agent", "/opt/acme/state"),
activeProfile("b", "pi", "relative/state"),
+14 -5
View File
@@ -140,7 +140,10 @@ export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaem
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
const components = { web, sessiond };
const commands = await commandsFor(components, { activeAgentProfile: options.activeAgentProfile, hasCommand: options.hasCommand ?? hasCommand });
const commands = await commandsFor(components, {
activeAgentProfile: options.activeAgentProfile,
hasCommand: options.hasCommand ?? hasCommand,
});
const messages = buildMessages(components, release, commands);
return {
...versionStatus,
@@ -416,7 +419,10 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
return version;
}
async function commandsFor(components: PiWebStatusResponse["components"], options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<PiWebStatusResponse["commands"]> {
async function commandsFor(components: PiWebStatusResponse["components"], options: {
activeAgentProfile: ActiveAgentProfileDescriptor | undefined;
hasCommand: (command: string) => Promise<boolean>;
}): Promise<PiWebStatusResponse["commands"]> {
const installation = preferredInstallation(components);
if (installation?.kind === "docker") return dockerCommands(installation);
@@ -467,7 +473,10 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
return cliCommands.restart ?? serviceCommands.restart;
}
export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<string | undefined> {
export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: {
activeAgentProfile: ActiveAgentProfileDescriptor | undefined;
hasCommand: (command: string) => Promise<boolean>;
}): Promise<string | undefined> {
if (restartCommand === undefined) return undefined;
if (installation?.kind === "pi-package") {
const profile = options.activeAgentProfile;
@@ -479,8 +488,8 @@ export async function updateCommandFor(installation: PiWebInstallationInfo | und
if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`;
}
if (installation?.kind !== "npm-global" || !(await hasCommand("npm"))) return undefined;
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`;
if (installation?.kind !== "npm-global" || !(await options.hasCommand("npm"))) return undefined;
return `npm install -g ${PI_WEB_PACKAGE_NAME} --allow-scripts=node-pty && ${restartCommand}`;
}
async function nativeServiceCommands(): Promise<NativeServiceCommands> {