Archived
fix(install): allow node-pty scripts with npm 12
This commit is contained in:
@@ -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.
|
||||
@@ -38,5 +38,9 @@ jobs:
|
||||
- name: 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
|
||||
run: npm run pack:dry
|
||||
|
||||
@@ -34,5 +34,8 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Smoke test npm 12 global package install
|
||||
run: npm run smoke:package-install
|
||||
|
||||
- name: Publish
|
||||
run: npm publish --access public --provenance
|
||||
|
||||
@@ -42,11 +42,13 @@ Requirements:
|
||||
Install and start PI WEB as per-user services:
|
||||
|
||||
```bash
|
||||
npm install -g @jmfederico/pi-web
|
||||
npm install -g @jmfederico/pi-web --allow-scripts=node-pty
|
||||
pi-web install
|
||||
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:
|
||||
|
||||
```text
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ ARG CACHE_BUST=local
|
||||
|
||||
RUN set -eux; \
|
||||
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_prefix="$(npm prefix -g)"; \
|
||||
peer_pi_bin="${global_root}/@jmfederico/pi-web/node_modules/.bin/pi"; \
|
||||
|
||||
+26
-3
@@ -94,6 +94,7 @@
|
||||
<a href="#tools-are-not-found">Tools are failing / node not found</a>
|
||||
<a href="#doctor-fails">What does doctor check?</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="#cannot-open">I cannot open the web UI</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.
|
||||
</p>
|
||||
<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>Native Windows:</strong> outside WSL is not the recommended path today.</li>
|
||||
</ul>
|
||||
@@ -166,8 +167,9 @@
|
||||
<p>
|
||||
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
|
||||
drift. On unsupported/manual-only platforms, native-service drift checks are skipped. Doctor also prints
|
||||
installed and running PI WEB versions and reports systemd lingering when relevant.
|
||||
drift. On unsupported/manual-only platforms, native-service drift checks are skipped. Doctor also checks
|
||||
that the <code>node-pty</code> native module can load, prints installed and running PI WEB versions, and reports
|
||||
systemd lingering when relevant.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
@@ -186,6 +188,27 @@
|
||||
</ul>
|
||||
</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">
|
||||
<h2>User service manager is unavailable</h2>
|
||||
<p>
|
||||
|
||||
+1
-1
@@ -337,7 +337,7 @@
|
||||
<strong>User service install</strong>
|
||||
<button class="copy-button" data-copy="#home-install">Copy</button>
|
||||
</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 doctor
|
||||
<span class="prompt">$</span> pi-web version
|
||||
|
||||
+6
-2
@@ -133,10 +133,14 @@
|
||||
<strong>User service install</strong>
|
||||
<button class="copy-button" data-copy="#linux-install">Copy</button>
|
||||
</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 doctor</code></pre>
|
||||
</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>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>
|
||||
@@ -185,7 +189,7 @@
|
||||
<strong>Manual processes</strong>
|
||||
<button class="copy-button" data-copy="#manual-install">Copy</button>
|
||||
</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="prompt">$</span> pi-web-sessiond
|
||||
|
||||
+1
-1
@@ -167,7 +167,7 @@ PI WEB gateway you opened
|
||||
<strong>Install on each target</strong>
|
||||
<button class="copy-button" data-copy="#target-install">Copy</button>
|
||||
</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 doctor</code></pre>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
npm install -g @jmfederico/pi-web
|
||||
npm install -g @jmfederico/pi-web --allow-scripts=node-pty
|
||||
pi-web install
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"prepack": "npm run build",
|
||||
"pack:dry": "npm pack --dry-run",
|
||||
"smoke:package-install": "node scripts/smoke-package-install.mjs",
|
||||
"prepublishOnly": "npm run verify",
|
||||
"publish:npm": "npm publish --access public",
|
||||
"prepare": "node scripts/install-git-hooks.mjs",
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user