fix: support pi package service entrypoints

This commit is contained in:
Federico Jaramillo Martinez
2026-05-19 16:56:28 +02:00
parent 492a6e141c
commit 32182a5539
4 changed files with 91 additions and 31 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Allow Pi package installs to create systemd services from bundled Pi Web entrypoints when `pi-web-server` and `pi-web-sessiond` are not on the service shell PATH.
+1 -1
View File
@@ -183,7 +183,7 @@ Then in Pi:
/pi-web doctor /pi-web doctor
``` ```
The Pi command is a convenience wrapper around the same service installer. `/pi-web logs` shows the last 100 journal lines; use `pi-web logs` in a shell when you want to follow logs continuously. The Pi command is a convenience wrapper around the same service installer. When installed this way, the service installer can use Pi Web's package-local server entrypoints, so `pi-web-server` and `pi-web-sessiond` do not need to be on your shell `PATH`. `/pi-web logs` shows the last 100 journal lines; use `pi-web logs` in a shell when you want to follow logs continuously.
Advanced users may run the binaries however they prefer: Advanced users may run the binaries however they prefer:
+1
View File
@@ -126,6 +126,7 @@
<section id="pi-package"> <section id="pi-package">
<h2>Install through Pi</h2> <h2>Install through Pi</h2>
<p>Pi Web is also published as a Pi package. This exposes a <code>/pi-web</code> command inside Pi.</p> <p>Pi Web is also published as a Pi package. This exposes a <code>/pi-web</code> command inside Pi.</p>
<p>When installed this way, <code>/pi-web install</code> can use Pi Web's package-local service entrypoints, so <code>pi-web-server</code> and <code>pi-web-sessiond</code> do not need to be on your shell <code>PATH</code>.</p>
<div class="code-card"> <div class="code-card">
<div class="copy-row"> <div class="copy-row">
<strong>Pi package path</strong> <strong>Pi package path</strong>
+84 -30
View File
@@ -4,6 +4,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os"; import { homedir, userInfo } from "node:os";
import { basename, dirname, join, resolve } from "node:path"; import { basename, dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { defaultPiWebConfigPath, examplePiWebConfig } from "./config.js"; import { defaultPiWebConfigPath, examplePiWebConfig } from "./config.js";
const serviceDir = join(homedir(), ".config", "systemd", "user"); const serviceDir = join(homedir(), ".config", "systemd", "user");
@@ -26,6 +27,16 @@ interface ServiceShell {
fallback: boolean; fallback: boolean;
} }
interface ServiceExecutable {
command: string;
checks: Check[];
}
interface ServiceExecutables {
sessiond: ServiceExecutable;
web: ServiceExecutable;
}
function run(command: string, args: string[], options: { check?: boolean } = {}): number { function run(command: string, args: string[], options: { check?: boolean } = {}): number {
const result = spawnSync(command, args, { stdio: "inherit" }); const result = spawnSync(command, args, { stdio: "inherit" });
const status = result.status ?? 1; const status = result.status ?? 1;
@@ -99,14 +110,12 @@ function systemdEscape(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
} }
function sessiondExec(): string { function packageRootPath(): string {
const configured = process.env["PI_WEB_SESSIOND_EXEC"]?.trim(); return dirname(dirname(fileURLToPath(import.meta.url)));
return configured === undefined || configured === "" ? "pi-web-sessiond" : configured;
} }
function webExec(): string { function packageEntrypointPath(name: "server" | "sessiond"): string {
const configured = process.env["PI_WEB_SERVER_EXEC"]?.trim(); return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js");
return configured === undefined || configured === "" ? "pi-web-server" : configured;
} }
function detectServiceShell(): ServiceShell { function detectServiceShell(): ServiceShell {
@@ -136,6 +145,59 @@ function systemdServiceShellQuote(value: string): string {
return serviceShellQuote(value.replaceAll("%", "%%").replaceAll("$", "$$")); return serviceShellQuote(value.replaceAll("%", "%%").replaceAll("$", "$$"));
} }
function checkSucceeds(command: string[]): boolean {
const [bin, ...args] = command;
return bin !== undefined && capture(bin, args).status === 0;
}
function serviceShellCanFindCommand(command: string): boolean {
if (!checkSucceeds(serviceShellCommand(commandCheck(command)))) return false;
return checkSucceeds(systemdUserServiceShellCommand(commandCheck(command)));
}
function readableFileCheck(path: string): string {
const quoted = serviceShellQuote(path);
return `test -r ${quoted} && printf '%s\\n' ${quoted}`;
}
function commandExecutable(command: string): ServiceExecutable {
const shell = serviceShellLabel();
return {
command,
checks: [
[`${shell} can find ${command}`, serviceShellCommand(commandCheck(command))],
[`systemd user ${shell} can find ${command}`, systemdUserServiceShellCommand(commandCheck(command))],
],
};
}
function bundledExecutable(command: string, entrypointPath: string): ServiceExecutable {
const shell = serviceShellLabel();
const check = readableFileCheck(entrypointPath);
return {
command: `node ${serviceShellQuote(entrypointPath)}`,
checks: [
[`${shell} can access bundled ${command} entrypoint`, serviceShellCommand(check)],
[`systemd user ${shell} can access bundled ${command} entrypoint`, systemdUserServiceShellCommand(check)],
],
};
}
function serviceExecutable(envName: "PI_WEB_SERVER_EXEC" | "PI_WEB_SESSIOND_EXEC", command: string, entrypointPath: string): ServiceExecutable {
const configured = process.env[envName]?.trim();
if (configured !== undefined && configured !== "") return { command: configured, checks: [] };
if (serviceShellCanFindCommand(command)) return commandExecutable(command);
if (existsSync(entrypointPath)) return bundledExecutable(command, entrypointPath);
return commandExecutable(command);
}
function resolveServiceExecutables(): ServiceExecutables {
return {
sessiond: serviceExecutable("PI_WEB_SESSIOND_EXEC", "pi-web-sessiond", packageEntrypointPath("sessiond")),
web: serviceExecutable("PI_WEB_SERVER_EXEC", "pi-web-server", packageEntrypointPath("server")),
};
}
function describeServiceShell(): string { function describeServiceShell(): string {
const shell = detectServiceShell(); const shell = detectServiceShell();
if (shell.fallback) { if (shell.fallback) {
@@ -146,13 +208,13 @@ function describeServiceShell(): string {
return shell.detected === undefined ? shell.name : `${shell.name} (${shell.detected})`; return shell.detected === undefined ? shell.name : `${shell.name} (${shell.detected})`;
} }
function sessiondUnit(): string { function sessiondUnit(executables: ServiceExecutables): string {
return `[Unit] return `[Unit]
Description=Pi Web session daemon Description=Pi Web session daemon
[Service] [Service]
Type=simple Type=simple
ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(`exec ${sessiondExec()}`)} ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(`exec ${executables.sessiond.command}`)}
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
@@ -161,7 +223,7 @@ WantedBy=default.target
`; `;
} }
function webUnit(options: InstallOptions): string { function webUnit(options: InstallOptions, executables: ServiceExecutables): string {
const configEnvironment = options.config === undefined ? "" : `Environment="PI_WEB_CONFIG=${systemdEscape(resolve(options.config))}"\n`; const configEnvironment = options.config === undefined ? "" : `Environment="PI_WEB_CONFIG=${systemdEscape(resolve(options.config))}"\n`;
return `[Unit] return `[Unit]
Description=Pi Web server Description=Pi Web server
@@ -170,7 +232,7 @@ Wants=${sessiondServiceName}
[Service] [Service]
Type=simple Type=simple
${configEnvironment}ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(`exec ${webExec()}`)} ${configEnvironment}ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(`exec ${executables.web.command}`)}
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
@@ -191,18 +253,19 @@ async function writeInitialConfig(options: InstallOptions): Promise<string> {
async function install(args: string[]): Promise<void> { async function install(args: string[]): Promise<void> {
const options = parseInstallOptions(args); const options = parseInstallOptions(args);
const executables = resolveServiceExecutables();
console.log("Running Pi Web install preflight checks..."); console.log("Running Pi Web install preflight checks...");
console.log(`Service shell: ${describeServiceShell()}`); console.log(`Service shell: ${describeServiceShell()}`);
if (!runChecks(installPreflightChecks())) { if (!runChecks(installPreflightChecks(executables))) {
printPathSetupAdvice(); printPathSetupAdvice();
throw new Error("Install preflight checks failed. Fix the missing commands above, then run `pi-web doctor` for more detail."); throw new Error("Install preflight checks failed. Fix the failed checks above, then run `pi-web doctor` for more detail.");
} }
const configPath = await writeInitialConfig(options); const configPath = await writeInitialConfig(options);
await mkdir(serviceDir, { recursive: true }); await mkdir(serviceDir, { recursive: true });
await writeFile(join(serviceDir, sessiondServiceName), sessiondUnit()); await writeFile(join(serviceDir, sessiondServiceName), sessiondUnit(executables));
await writeFile(join(serviceDir, webServiceName), webUnit(options)); await writeFile(join(serviceDir, webServiceName), webUnit(options, executables));
run("systemctl", ["--user", "daemon-reload"], { check: true }); run("systemctl", ["--user", "daemon-reload"], { check: true });
run("systemctl", ["--user", "enable", "--now", sessiondServiceName], { check: true }); run("systemctl", ["--user", "enable", "--now", sessiondServiceName], { check: true });
@@ -271,32 +334,22 @@ function nodeVersionCheck(): string {
].join(" && "); ].join(" && ");
} }
function installPreflightChecks(): Check[] { function installPreflightChecks(executables: ServiceExecutables = resolveServiceExecutables()): Check[] {
const shell = serviceShellLabel(); const shell = serviceShellLabel();
const checks: Check[] = [ return [
["systemctl --user", ["systemctl", "--user", "--version"]], ["systemctl --user", ["systemctl", "--user", "--version"]],
[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())], [`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`systemd user ${shell} can find node >= 22`, systemdUserServiceShellCommand(nodeVersionCheck())], [`systemd user ${shell} can find node >= 22`, systemdUserServiceShellCommand(nodeVersionCheck())],
...executables.web.checks,
...executables.sessiond.checks,
]; ];
if (process.env["PI_WEB_SERVER_EXEC"] === undefined) {
checks.push(
[`${shell} can find pi-web-server`, serviceShellCommand(commandCheck("pi-web-server"))],
[`systemd user ${shell} can find pi-web-server`, systemdUserServiceShellCommand(commandCheck("pi-web-server"))],
);
}
if (process.env["PI_WEB_SESSIOND_EXEC"] === undefined) {
checks.push(
[`${shell} can find pi-web-sessiond`, serviceShellCommand(commandCheck("pi-web-sessiond"))],
[`systemd user ${shell} can find pi-web-sessiond`, systemdUserServiceShellCommand(commandCheck("pi-web-sessiond"))],
);
}
return checks;
} }
function doctorChecks(): Check[] { function doctorChecks(): Check[] {
const shell = serviceShellLabel(); const shell = serviceShellLabel();
const executables = resolveServiceExecutables();
return [ return [
...installPreflightChecks(), ...installPreflightChecks(executables),
[`${shell} can find npm`, serviceShellCommand(commandCheck("npm"))], [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"))],
[`${shell} can find pi`, serviceShellCommand(commandCheck("pi"))], [`${shell} can find pi`, serviceShellCommand(commandCheck("pi"))],
[`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandCheck("pi"))], [`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandCheck("pi"))],
@@ -355,6 +408,7 @@ function doctor(): void {
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.");
printPathSetupAdvice(); printPathSetupAdvice();
process.exitCode = 1; process.exitCode = 1;
} }