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
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Allow npm 12 global installs and updates to run node-pty's required native-module installation scripts, and diagnose blocked native modules before installing services.
+4
View File
@@ -38,5 +38,9 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
- name: Smoke test npm 12 global package install
if: runner.os == 'Linux'
run: npm run smoke:package-install
- name: Check npm package contents - name: Check npm package contents
run: npm run pack:dry run: npm run pack:dry
+3
View File
@@ -34,5 +34,8 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
- name: Smoke test npm 12 global package install
run: npm run smoke:package-install
- name: Publish - name: Publish
run: npm publish --access public --provenance run: npm publish --access public --provenance
+3 -1
View File
@@ -42,11 +42,13 @@ Requirements:
Install and start PI WEB as per-user services: Install and start PI WEB as per-user services:
```bash ```bash
npm install -g @jmfederico/pi-web npm install -g @jmfederico/pi-web --allow-scripts=node-pty
pi-web install pi-web install
pi-web doctor pi-web doctor
``` ```
On npm 12, the scoped flag lets `node-pty` prepare its required native module without enabling install scripts for other dependencies.
Then open: Then open:
```text ```text
+1 -1
View File
@@ -30,7 +30,7 @@ ARG CACHE_BUST=local
RUN set -eux; \ RUN set -eux; \
echo "PI WEB Docker build cache bust: ${CACHE_BUST}"; \ echo "PI WEB Docker build cache bust: ${CACHE_BUST}"; \
npm install -g --omit=dev --include=peer --no-audit --no-fund "@jmfederico/pi-web@${PI_WEB_VERSION}"; \ npm install -g --omit=dev --include=peer --no-audit --no-fund "@jmfederico/pi-web@${PI_WEB_VERSION}" --allow-scripts=node-pty; \
global_root="$(npm root -g)"; \ global_root="$(npm root -g)"; \
global_prefix="$(npm prefix -g)"; \ global_prefix="$(npm prefix -g)"; \
peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"; \ peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"; \
+26 -3
View File
@@ -94,6 +94,7 @@
<a href="#tools-are-not-found">Tools are failing / node not found</a> <a href="#tools-are-not-found">Tools are failing / node not found</a>
<a href="#doctor-fails">What does doctor check?</a> <a href="#doctor-fails">What does doctor check?</a>
<a href="#nvm-fnm-asdf">nvm, fnm, or asdf issues</a> <a href="#nvm-fnm-asdf">nvm, fnm, or asdf issues</a>
<a href="#node-pty-native-module">node-pty native module is missing</a>
<a href="#systemd-not-found">User service manager is unavailable</a> <a href="#systemd-not-found">User service manager is unavailable</a>
<a href="#cannot-open">I cannot open the web UI</a> <a href="#cannot-open">I cannot open the web UI</a>
<a href="#public-internet">Can I expose this publicly?</a> <a href="#public-internet">Can I expose this publicly?</a>
@@ -112,7 +113,7 @@
a supported per-user service manager when one is available. a supported per-user service manager when one is available.
</p> </p>
<ul> <ul>
<li><strong>User-service install:</strong> use <code>npm install -g @jmfederico/pi-web</code> and <code>pi-web install</code>.</li> <li><strong>User-service install:</strong> use <code>npm install -g @jmfederico/pi-web --allow-scripts=node-pty</code> and <code>pi-web install</code>.</li>
<li><strong>WSL:</strong> if your distro has systemd enabled, the installer may work; otherwise use the manual run path.</li> <li><strong>WSL:</strong> if your distro has systemd enabled, the installer may work; otherwise use the manual run path.</li>
<li><strong>Native Windows:</strong> outside WSL is not the recommended path today.</li> <li><strong>Native Windows:</strong> outside WSL is not the recommended path today.</li>
</ul> </ul>
@@ -166,8 +167,9 @@
<p> <p>
Missing plan requirements fail doctor and include login-file guidance. Manager, timeout, malformed-output, Missing plan requirements fail doctor and include login-file guidance. Manager, timeout, malformed-output,
and cleanup failures are reported as probe infrastructure problems rather than being mislabeled as PATH and cleanup failures are reported as probe infrastructure problems rather than being mislabeled as PATH
drift. On unsupported/manual-only platforms, native-service drift checks are skipped. Doctor also prints drift. On unsupported/manual-only platforms, native-service drift checks are skipped. Doctor also checks
installed and running PI WEB versions and reports systemd lingering when relevant. that the <code>node-pty</code> native module can load, prints installed and running PI WEB versions, and reports
systemd lingering when relevant.
</p> </p>
</article> </article>
@@ -186,6 +188,27 @@
</ul> </ul>
</article> </article>
<article id="node-pty-native-module" class="faq-item">
<h2><code>pi-web-sessiond</code> cannot load <code>pty.node</code></h2>
<p>
npm 12 blocks dependency installation scripts unless they are explicitly approved. If PI WEB was installed
without approving <code>node-pty</code>, its required native module can be missing even though npm reported a
successful installation.
</p>
<p>Reinstall PI WEB with approval limited to <code>node-pty</code>:</p>
<div class="code-card">
<div class="copy-row">
<strong>Allow the node-pty installation scripts</strong>
<button class="copy-button" data-copy="#node-pty-install">Copy</button>
</div>
<pre id="node-pty-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web --allow-scripts=node-pty</code></pre>
</div>
<p>
Then run <code>pi-web install</code> again, or restart <code>pi-web-sessiond</code> if you run PI WEB manually.
The flag does not enable installation scripts for other dependencies.
</p>
</article>
<article id="systemd-not-found" class="faq-item"> <article id="systemd-not-found" class="faq-item">
<h2>User service manager is unavailable</h2> <h2>User service manager is unavailable</h2>
<p> <p>
+1 -1
View File
@@ -337,7 +337,7 @@
<strong>User service install</strong> <strong>User service install</strong>
<button class="copy-button" data-copy="#home-install">Copy</button> <button class="copy-button" data-copy="#home-install">Copy</button>
</div> </div>
<pre id="home-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web <pre id="home-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web --allow-scripts=node-pty
<span class="prompt">$</span> pi-web install <span class="prompt">$</span> pi-web install
<span class="prompt">$</span> pi-web doctor <span class="prompt">$</span> pi-web doctor
<span class="prompt">$</span> pi-web version <span class="prompt">$</span> pi-web version
+6 -2
View File
@@ -133,10 +133,14 @@
<strong>User service install</strong> <strong>User service install</strong>
<button class="copy-button" data-copy="#linux-install">Copy</button> <button class="copy-button" data-copy="#linux-install">Copy</button>
</div> </div>
<pre id="linux-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web <pre id="linux-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web --allow-scripts=node-pty
<span class="prompt">$</span> pi-web install <span class="prompt">$</span> pi-web install
<span class="prompt">$</span> pi-web doctor</code></pre> <span class="prompt">$</span> pi-web doctor</code></pre>
</div> </div>
<p>
The scoped <code>--allow-scripts=node-pty</code> flag lets npm 12 run the native-module installation required
by PI WEB terminals without enabling install scripts for other dependencies.
</p>
<p>Then open <a href="http://127.0.0.1:8504">http://127.0.0.1:8504</a>.</p> <p>Then open <a href="http://127.0.0.1:8504">http://127.0.0.1:8504</a>.</p>
<p>If preflight fails, no config or existing services are changed. Follow the detected shell guidance: zsh services read <code>~/.zprofile</code>, not interactive-only <code>~/.zshrc</code>; bash uses <code>~/.bash_profile</code> or <code>~/.profile</code>.</p> <p>If preflight fails, no config or existing services are changed. Follow the detected shell guidance: zsh services read <code>~/.zprofile</code>, not interactive-only <code>~/.zshrc</code>; bash uses <code>~/.bash_profile</code> or <code>~/.profile</code>.</p>
<p>On Linux servers, also consider <code>sudo loginctl enable-linger "$USER"</code> so user services survive logout/reboot.</p> <p>On Linux servers, also consider <code>sudo loginctl enable-linger "$USER"</code> so user services survive logout/reboot.</p>
@@ -185,7 +189,7 @@
<strong>Manual processes</strong> <strong>Manual processes</strong>
<button class="copy-button" data-copy="#manual-install">Copy</button> <button class="copy-button" data-copy="#manual-install">Copy</button>
</div> </div>
<pre id="manual-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web <pre id="manual-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web --allow-scripts=node-pty
<span class="comment"># Terminal 1</span> <span class="comment"># Terminal 1</span>
<span class="prompt">$</span> pi-web-sessiond <span class="prompt">$</span> pi-web-sessiond
+1 -1
View File
@@ -167,7 +167,7 @@ PI WEB gateway you opened
<strong>Install on each target</strong> <strong>Install on each target</strong>
<button class="copy-button" data-copy="#target-install">Copy</button> <button class="copy-button" data-copy="#target-install">Copy</button>
</div> </div>
<pre id="target-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web <pre id="target-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web --allow-scripts=node-pty
<span class="prompt">$</span> pi-web install <span class="prompt">$</span> pi-web install
<span class="prompt">$</span> pi-web doctor</code></pre> <span class="prompt">$</span> pi-web doctor</code></pre>
</div> </div>
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -eu set -eu
npm install -g @jmfederico/pi-web npm install -g @jmfederico/pi-web --allow-scripts=node-pty
pi-web install pi-web install
+1
View File
@@ -46,6 +46,7 @@
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
"prepack": "npm run build", "prepack": "npm run build",
"pack:dry": "npm pack --dry-run", "pack:dry": "npm pack --dry-run",
"smoke:package-install": "node scripts/smoke-package-install.mjs",
"prepublishOnly": "npm run verify", "prepublishOnly": "npm run verify",
"publish:npm": "npm publish --access public", "publish:npm": "npm publish --access public",
"prepare": "node scripts/install-git-hooks.mjs", "prepare": "node scripts/install-git-hooks.mjs",
+132
View File
@@ -0,0 +1,132 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
const NPM_VERSION = "12.0.1";
const MARKER = "pi-web-package-pty-ok";
const execFileAsync = promisify(execFile);
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
if (process.platform === "win32") {
throw new Error("The installed-package PTY smoke test requires a POSIX shell");
}
const npmExecPath = process.env["npm_execpath"];
if (npmExecPath === undefined || npmExecPath === "") {
throw new Error("npm_execpath is required; run this check through `npm run smoke:package-install`");
}
const root = await mkdtemp(join(tmpdir(), "pi-web-package-install-"));
try {
const packDir = join(root, "pack");
const npmToolDir = join(root, "npm-tool");
const globalPrefix = join(root, "global");
await Promise.all([
mkdir(packDir, { recursive: true }),
mkdir(join(globalPrefix, "lib"), { recursive: true }),
mkdir(npmToolDir, { recursive: true }),
writeFile(join(npmToolDir, "package.json"), '{"private":true}\n'),
]);
const packOutput = await runNpm(npmExecPath, ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], repoRoot);
const tarballPath = join(packDir, packageTarballFilename(packOutput));
await runNpm(npmExecPath, [
"install",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--no-package-lock",
"--no-save",
`npm@${NPM_VERSION}`,
], npmToolDir);
const npm12ExecPath = join(npmToolDir, "node_modules", "npm", "bin", "npm-cli.js");
await runNpm(npm12ExecPath, [
"install",
"--global",
tarballPath,
"--prefix",
globalPrefix,
"--allow-scripts=node-pty",
"--no-audit",
"--no-fund",
], root);
const packageRoot = join(globalPrefix, "lib", "node_modules", "@jmfederico", "pi-web");
await smokeInstalledTerminalService(packageRoot);
console.log(`Installed-package PTY smoke test passed with npm ${NPM_VERSION}.`);
} finally {
await rm(root, { recursive: true, force: true });
}
async function runNpm(npmCliPath, args, cwd) {
const result = await execFileAsync(process.execPath, [npmCliPath, ...args], {
cwd,
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
timeout: 180_000,
});
return result.stdout;
}
function packageTarballFilename(output) {
const parsed = JSON.parse(output);
if (!Array.isArray(parsed) || parsed.length !== 1 || typeof parsed[0]?.filename !== "string") {
throw new Error("npm pack returned an unexpected result");
}
return parsed[0].filename;
}
async function smokeInstalledTerminalService(packageRoot) {
const requireFromPackage = createRequire(join(packageRoot, "package.json"));
const nodePtyPackageJsonPath = requireFromPackage.resolve("node-pty/package.json");
const nodePtyPackage = JSON.parse(await readFile(nodePtyPackageJsonPath, "utf8"));
if (typeof nodePtyPackage.version !== "string" || nodePtyPackage.version.includes("-")) {
throw new Error(`Installed package resolved a non-stable node-pty version: ${String(nodePtyPackage.version)}`);
}
const terminalModuleUrl = pathToFileURL(join(packageRoot, "dist", "server", "terminals", "terminalService.js")).href;
const { TerminalService } = await import(terminalModuleUrl);
const previousShell = process.env["SHELL"];
process.env["SHELL"] = "/bin/sh";
const service = new TerminalService();
try {
const run = service.runCommand({
origin: "package-smoke",
projectId: "package-smoke",
workspaceId: "package-smoke",
cwd: packageRoot,
title: "Installed package PTY smoke test",
command: `printf '%s' '${MARKER}'`,
});
let output = "";
let detach = () => undefined;
const exitCode = await new Promise((resolvePromise, reject) => {
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for installed node-pty output: ${JSON.stringify(output)}`)), 10_000);
try {
detach = service.attach(run.terminalId, {
output: (data) => { output += data; },
exit: (code) => {
clearTimeout(timeout);
resolvePromise(code);
},
});
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
detach();
if (exitCode !== 0) throw new Error(`Installed PTY command exited with ${String(exitCode)}`);
if (!output.includes(MARKER)) throw new Error(`Installed PTY output did not contain ${MARKER}: ${JSON.stringify(output)}`);
} finally {
service.dispose();
if (previousShell === undefined) delete process.env["SHELL"];
else process.env["SHELL"] = previousShell;
}
}
+18 -5
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
import { defaultPiWebConfigPath, defaultPiWebDataDir, effectivePiWebConfig, examplePiWebConfig } from "./config.js"; import { defaultPiWebConfigPath, defaultPiWebDataDir, effectivePiWebConfig, examplePiWebConfig } from "./config.js";
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js"; import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
import { checkNodePtyNativeModule, formatNodePtyNativeModuleCheck } from "./server/diagnostics/nodePtyNativeModule.js";
import { import {
installNativeServiceCandidate, installNativeServiceCandidate,
nativeServiceInstallFailureNeedsPathAdvice, nativeServiceInstallFailureNeedsPathAdvice,
@@ -664,6 +665,9 @@ async function install(args: string[]): Promise<void> {
console.log(`Running PI WEB ${options.mode} install preflight checks...`); console.log(`Running PI WEB ${options.mode} install preflight checks...`);
console.log(`Service backend: ${backend.label}`); console.log(`Service backend: ${backend.label}`);
console.log(`Service shell: ${describeServiceShell()}`); 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, { const result = await installNativeServiceCandidate(candidate, {
probe: createNativeServiceAuthoritativeProbe(), probe: createNativeServiceAuthoritativeProbe(),
fileExists: regularFileExists, fileExists: regularFileExists,
@@ -967,9 +971,9 @@ function printPathSetupAdvice(shell: NativeServiceShell = detectServiceShell()):
export function doctorExitCode( export function doctorExitCode(
generalReadinessOk: boolean, generalReadinessOk: boolean,
nativeServicePlanOk: boolean, nativeServicePlanOk: boolean,
nodePtySpawnHelperOk: boolean, nodePtyRuntimeOk: boolean,
): 0 | 1 { ): 0 | 1 {
return generalReadinessOk && nativeServicePlanOk && nodePtySpawnHelperOk ? 0 : 1; return generalReadinessOk && nativeServicePlanOk && nodePtyRuntimeOk ? 0 : 1;
} }
async function doctor(): Promise<void> { 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):"); console.log("\nGeneral login-shell readiness (separate from native-service requirements):");
const generalReadinessOk = runChecks(generalDoctorChecks()); const generalReadinessOk = runChecks(generalDoctorChecks());
printOptionalDoctorChecks(); printOptionalDoctorChecks();
const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
console.log("\nNative terminal runtime readiness:");
const nodePtyNativeModuleOk = printNodePtyNativeModuleCheck();
const nodePtySpawnHelperOk = nodePtyNativeModuleOk ? printNodePtyDarwinSpawnHelperCheck() : true;
let nativeServiceReport: NativeServiceDoctorReport | null = null; let nativeServiceReport: NativeServiceDoctorReport | null = null;
if (backend !== undefined) { if (backend !== undefined) {
@@ -1025,7 +1032,13 @@ async function doctor(): Promise<void> {
console.log(`\n${manualRunAdvice()}`); 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 { function printNodePtyDarwinSpawnHelperCheck(): boolean {
@@ -1049,7 +1062,7 @@ Usage:
pi-web version pi-web version
Recommended install: Recommended install:
npm install -g @jmfederico/pi-web npm install -g @jmfederico/pi-web --allow-scripts=node-pty
pi-web install pi-web install
Development service install from a checkout: 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/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("COPY internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base");
expect(dockerfile).toContain("--include=peer"); 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).toContain('peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"');
expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@"); expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@");
expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker"); 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"); 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([ it.each([
activeProfile("a", "acme-agent", "/opt/acme/state"), activeProfile("a", "acme-agent", "/opt/acme/state"),
activeProfile("b", "pi", "relative/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 { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true); const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
const components = { web, sessiond }; 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); const messages = buildMessages(components, release, commands);
return { return {
...versionStatus, ...versionStatus,
@@ -416,7 +419,10 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
return version; 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); const installation = preferredInstallation(components);
if (installation?.kind === "docker") return dockerCommands(installation); if (installation?.kind === "docker") return dockerCommands(installation);
@@ -467,7 +473,10 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
return cliCommands.restart ?? serviceCommands.restart; 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 (restartCommand === undefined) return undefined;
if (installation?.kind === "pi-package") { if (installation?.kind === "pi-package") {
const profile = options.activeAgentProfile; 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; 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}`; 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; if (installation?.kind !== "npm-global" || !(await options.hasCommand("npm"))) return undefined;
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`; return `npm install -g ${PI_WEB_PACKAGE_NAME} --allow-scripts=node-pty && ${restartCommand}`;
} }
async function nativeServiceCommands(): Promise<NativeServiceCommands> { async function nativeServiceCommands(): Promise<NativeServiceCommands> {