diff --git a/.changeset/fix-doctor-missing-command.md b/.changeset/fix-doctor-missing-command.md new file mode 100644 index 0000000..e005e23 --- /dev/null +++ b/.changeset/fix-doctor-missing-command.md @@ -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. diff --git a/src/cli.ts b/src/cli.ts index 370e1f5..9260caf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,38 @@ interface InstallOptions { type Check = [string, string[]]; 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 { name: SupportedShell; executable: string; @@ -45,9 +77,15 @@ function run(command: string, args: string[], options: { check?: boolean } = {}) return status; } +function outputText(value: unknown): string { + return typeof value === "string" ? value : ""; +} + function capture(command: string, args: string[]): { status: number; stdout: string; stderr: string } { 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 { @@ -252,6 +290,7 @@ async function writeInitialConfig(options: InstallOptions): Promise { } async function install(args: string[]): Promise { + assertSystemdUserServicesSupported("pi-web install"); const options = parseInstallOptions(args); const executables = resolveServiceExecutables(); @@ -292,6 +331,7 @@ async function install(args: string[]): Promise { } async function uninstall(): Promise { + assertSystemdUserServicesSupported("pi-web uninstall"); run("systemctl", ["--user", "disable", "--now", webServiceName]); run("systemctl", ["--user", "disable", "--now", sessiondServiceName]); await rm(join(serviceDir, webServiceName), { force: true }); @@ -301,10 +341,12 @@ async function uninstall(): Promise { } function serviceAction(action: "start" | "stop" | "restart" | "status"): void { + assertSystemdUserServicesSupported(`pi-web ${action}`); run("systemctl", ["--user", action, sessiondServiceName, webServiceName], { check: action !== "status" }); } function logs(): void { + assertSystemdUserServicesSupported("pi-web logs"); run("journalctl", ["--user", "-u", sessiondServiceName, "-u", webServiceName, "-f"]); } @@ -348,6 +390,13 @@ function installPreflightChecks(executables: ServiceExecutables = resolveService function doctorChecks(): Check[] { 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(); return [ ...installPreflightChecks(executables), @@ -393,26 +442,39 @@ function printPathSetupAdvice(): void { } function doctor(): void { + console.log(`Platform: ${platformLabel()}`); console.log(`Service shell: ${describeServiceShell()}`); + if (!supportsSystemdUserServices()) { + console.log(`- Linux systemd user service checks skipped on ${platformLabel()}`); + } const ok = runChecks(doctorChecks()); const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck(); - const linger = isLingerEnabled(); - if (linger === true) { - console.log("✓ systemd user lingering enabled"); - } else if (linger === false) { - console.log("✗ systemd user lingering disabled"); - console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`); + if (supportsSystemdUserServices()) { + const linger = isLingerEnabled(); + if (linger === true) { + console.log("✓ systemd user lingering enabled"); + } else if (linger === false) { + 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 { - console.log("? systemd user lingering unknown"); - console.log(` Recommended on servers: sudo loginctl enable-linger ${userInfo().username}`); + console.log(`- systemd user lingering skipped on ${platformLabel()}`); } 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("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(); } + + if (ok && !supportsSystemdUserServices()) { + console.log(`\n${manualRunAdvice()}`); + } + if (!ok || !nodePtySpawnHelperOk) process.exitCode = 1; } diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 6851943..57a4375 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -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 { tmpdir } from "node:os"; import type { FastifyInstance } from "fastify"; @@ -15,7 +15,7 @@ let tempDir: string; let projectDir: string; 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"); app = await buildApp({ projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), diff --git a/src/server/diagnostics/nodePtySpawnHelper.test.ts b/src/server/diagnostics/nodePtySpawnHelper.test.ts index 04ae203..d63bf0c 100644 --- a/src/server/diagnostics/nodePtySpawnHelper.test.ts +++ b/src/server/diagnostics/nodePtySpawnHelper.test.ts @@ -42,6 +42,8 @@ describe("node-pty macOS spawn-helper diagnostics", () => { 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}'`); + 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 () => { diff --git a/src/server/diagnostics/nodePtySpawnHelper.ts b/src/server/diagnostics/nodePtySpawnHelper.ts index 0370c73..c4b25e2 100644 --- a/src/server/diagnostics/nodePtySpawnHelper.ts +++ b/src/server/diagnostics/nodePtySpawnHelper.ts @@ -98,7 +98,7 @@ export function formatNodePtyDarwinSpawnHelperCheck(check: NodePtyDarwinSpawnHel ` PI WEB tracking issue: ${PI_WEB_SPAWN_HELPER_ISSUE_URL}`, " Proposed workaround:", ` ${check.fixCommand}`, - " Then restart the pi-web-sessiond/pi-web-server processes.", + " Then run `pi-web doctor` again and retry opening a terminal.", ], }; }