fix: make doctor platform-aware

This commit is contained in:
Federico Jaramillo Martinez
2026-05-25 14:56:21 +02:00
parent 4bfd4acd40
commit 57a6a4a69f
5 changed files with 82 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Improve `pi-web doctor` to report missing commands safely, skip Linux systemd checks on non-Linux platforms, and avoid misleading restart advice after the macOS node-pty permission workaround.
+72 -10
View File
@@ -21,6 +21,38 @@ interface InstallOptions {
type Check = [string, string[]]; type Check = [string, string[]];
type SupportedShell = "bash" | "zsh" | "fish"; type SupportedShell = "bash" | "zsh" | "fish";
function platformLabel(): string {
if (process.platform === "darwin") return "macOS";
if (process.platform === "linux") return "Linux";
if (process.platform === "win32") return "Windows";
return process.platform;
}
function supportsSystemdUserServices(): boolean {
return process.platform === "linux";
}
function manualRunAdvice(): string {
return [
"Run PI WEB manually from a checkout:",
" npm run start:sessiond",
" PI_WEB_PORT=8504 npm start",
"",
"For development in one terminal:",
" npm run dev",
"",
"For split development, keep sessiond separate and run web/API plus Vite UI separately:",
" npm run dev:sessiond",
" npm run dev:web",
" npm run dev:client",
].join("\n");
}
function assertSystemdUserServicesSupported(command: string): void {
if (supportsSystemdUserServices()) return;
throw new Error(`\`${command}\` requires Linux systemd user services and is not supported on ${platformLabel()}.\n\n${manualRunAdvice()}`);
}
interface ServiceShell { interface ServiceShell {
name: SupportedShell; name: SupportedShell;
executable: string; executable: string;
@@ -45,9 +77,15 @@ function run(command: string, args: string[], options: { check?: boolean } = {})
return status; return status;
} }
function outputText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function capture(command: string, args: string[]): { status: number; stdout: string; stderr: string } { function capture(command: string, args: string[]): { status: number; stdout: string; stderr: string } {
const result = spawnSync(command, args, { encoding: "utf8" }); const result = spawnSync(command, args, { encoding: "utf8" });
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr }; const errorMessage = result.error instanceof Error ? result.error.message : "";
const stderr = outputText(result.stderr);
return { status: result.status ?? 1, stdout: outputText(result.stdout), stderr: stderr === "" ? errorMessage : stderr };
} }
function hasCommand(command: string): boolean { function hasCommand(command: string): boolean {
@@ -252,6 +290,7 @@ async function writeInitialConfig(options: InstallOptions): Promise<string> {
} }
async function install(args: string[]): Promise<void> { async function install(args: string[]): Promise<void> {
assertSystemdUserServicesSupported("pi-web install");
const options = parseInstallOptions(args); const options = parseInstallOptions(args);
const executables = resolveServiceExecutables(); const executables = resolveServiceExecutables();
@@ -292,6 +331,7 @@ async function install(args: string[]): Promise<void> {
} }
async function uninstall(): Promise<void> { async function uninstall(): Promise<void> {
assertSystemdUserServicesSupported("pi-web uninstall");
run("systemctl", ["--user", "disable", "--now", webServiceName]); run("systemctl", ["--user", "disable", "--now", webServiceName]);
run("systemctl", ["--user", "disable", "--now", sessiondServiceName]); run("systemctl", ["--user", "disable", "--now", sessiondServiceName]);
await rm(join(serviceDir, webServiceName), { force: true }); await rm(join(serviceDir, webServiceName), { force: true });
@@ -301,10 +341,12 @@ async function uninstall(): Promise<void> {
} }
function serviceAction(action: "start" | "stop" | "restart" | "status"): void { function serviceAction(action: "start" | "stop" | "restart" | "status"): void {
assertSystemdUserServicesSupported(`pi-web ${action}`);
run("systemctl", ["--user", action, sessiondServiceName, webServiceName], { check: action !== "status" }); run("systemctl", ["--user", action, sessiondServiceName, webServiceName], { check: action !== "status" });
} }
function logs(): void { function logs(): void {
assertSystemdUserServicesSupported("pi-web logs");
run("journalctl", ["--user", "-u", sessiondServiceName, "-u", webServiceName, "-f"]); run("journalctl", ["--user", "-u", sessiondServiceName, "-u", webServiceName, "-f"]);
} }
@@ -348,6 +390,13 @@ function installPreflightChecks(executables: ServiceExecutables = resolveService
function doctorChecks(): Check[] { function doctorChecks(): Check[] {
const shell = serviceShellLabel(); const shell = serviceShellLabel();
if (!supportsSystemdUserServices()) {
return [
[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`${shell} can find npm`, serviceShellCommand(commandCheck("npm"))],
[`${shell} can find pi`, serviceShellCommand(commandCheck("pi"))],
];
}
const executables = resolveServiceExecutables(); const executables = resolveServiceExecutables();
return [ return [
...installPreflightChecks(executables), ...installPreflightChecks(executables),
@@ -393,26 +442,39 @@ function printPathSetupAdvice(): void {
} }
function doctor(): void { function doctor(): void {
console.log(`Platform: ${platformLabel()}`);
console.log(`Service shell: ${describeServiceShell()}`); console.log(`Service shell: ${describeServiceShell()}`);
if (!supportsSystemdUserServices()) {
console.log(`- Linux systemd user service checks skipped on ${platformLabel()}`);
}
const ok = runChecks(doctorChecks()); const ok = runChecks(doctorChecks());
const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck(); const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
const linger = isLingerEnabled(); if (supportsSystemdUserServices()) {
if (linger === true) { const linger = isLingerEnabled();
console.log("✓ systemd user lingering enabled"); if (linger === true) {
} else if (linger === false) { console.log("✓ systemd user lingering enabled");
console.log("✗ systemd user lingering disabled"); } else if (linger === false) {
console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`); console.log("✗ systemd user lingering disabled");
console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`);
} else {
console.log("? systemd user lingering unknown");
console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`);
}
} else { } else {
console.log("? systemd user lingering unknown"); console.log(`- systemd user lingering skipped on ${platformLabel()}`);
console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`);
} }
if (!ok) { if (!ok) {
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("\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."); if (supportsSystemdUserServices()) console.log("If a bundled entrypoint is not accessible, reinstall or update the PI WEB package.");
printPathSetupAdvice(); printPathSetupAdvice();
} }
if (ok && !supportsSystemdUserServices()) {
console.log(`\n${manualRunAdvice()}`);
}
if (!ok || !nodePtySpawnHelperOk) process.exitCode = 1; if (!ok || !nodePtySpawnHelperOk) process.exitCode = 1;
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"; import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
@@ -15,7 +15,7 @@ let tempDir: string;
let projectDir: string; let projectDir: string;
beforeEach(async () => { beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-app-test-")); tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
projectDir = join(tempDir, "project"); projectDir = join(tempDir, "project");
app = await buildApp({ app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
@@ -42,6 +42,8 @@ describe("node-pty macOS spawn-helper diagnostics", () => {
expect(formatted.ok).toBe(false); expect(formatted.ok).toBe(false);
expect(formatted.lines).toContain(` PI WEB tracking issue: ${PI_WEB_SPAWN_HELPER_ISSUE_URL}`); expect(formatted.lines).toContain(` PI WEB tracking issue: ${PI_WEB_SPAWN_HELPER_ISSUE_URL}`);
expect(formatted.lines).toContain(` chmod +x '${fixture.helperPath}'`); expect(formatted.lines).toContain(` chmod +x '${fixture.helperPath}'`);
expect(formatted.lines).toContain(" Then run `pi-web doctor` again and retry opening a terminal.");
expect(formatted.lines.join("\n")).not.toContain("restart");
}); });
it("passes when the selected helper is executable", async () => { it("passes when the selected helper is executable", async () => {
+1 -1
View File
@@ -98,7 +98,7 @@ export function formatNodePtyDarwinSpawnHelperCheck(check: NodePtyDarwinSpawnHel
` PI WEB tracking issue: ${PI_WEB_SPAWN_HELPER_ISSUE_URL}`, ` PI WEB tracking issue: ${PI_WEB_SPAWN_HELPER_ISSUE_URL}`,
" Proposed workaround:", " Proposed workaround:",
` ${check.fixCommand}`, ` ${check.fixCommand}`,
" Then restart the pi-web-sessiond/pi-web-server processes.", " Then run `pi-web doctor` again and retry opening a terminal.",
], ],
}; };
} }