fix(cli): diagnose native service plans in manager context

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 00:41:51 +02:00
parent 3ac6679952
commit dde48b3b11
11 changed files with 1021 additions and 164 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Validate install and doctor service requirements in the real systemd or launchd manager context before changing native services, with plan-specific PATH guidance and safe probe cleanup. Thanks to @blain3white for the original report, reproduction, and diagnosis.
+2
View File
@@ -64,6 +64,8 @@ pi-web version
pi-web uninstall
```
`pi-web install` validates the exact production or development service plan inside the native user-service manager before changing config or replacing services. `pi-web doctor` repeats manager-context diagnostics, labels prospective production checks when an installed command strategy cannot be reconstructed, and keeps general shell/Pi/npm readiness separate from service-start requirements.
For more install options, including one-line install, Pi package install, WSL/manual usage, and remote access, see the [installation guide](https://pi-web.dev/install).
## Core model
+12 -8
View File
@@ -131,8 +131,9 @@
<h2>Tools are failing, node is not found, or Pi cannot find commands</h2>
<p>
The shell environment needs to be set up so login shells have the required PATH entries for PI WEB, Pi,
and any tools your agents need. PI WEB services run commands through a non-interactive login shell, so
an interactive terminal can work while services fail.
and any tools your agents need. PI WEB services run commands through a non-interactive login shell owned
by systemd or launchd, so an interactive terminal—or even a caller-invoked login shell—can work while the
native service fails.
</p>
<div class="code-card">
<div class="copy-row">
@@ -156,14 +157,17 @@
<article id="doctor-fails" class="faq-item">
<h2>What does <code>pi-web doctor</code> check?</h2>
<p>
It checks whether the service shell and native service environment can find Node 22+, npm, Pi, and the Pi
Web binaries. It also prints installed and running PI WEB versions when available, reports optional ripgrep
availability for faster all-file <code>@</code>-mention suggestions, uses a bounded filesystem fallback when
ripgrep is unavailable, and reports user service lingering when relevant for server-style installs.
It keeps two kinds of checks separate. General login-shell readiness covers Node 22+, npm, Pi, and optional
ripgrep. Native-service diagnostics validate only the exact prerequisites of the selected service plan in
the real systemd user-manager or launchd <code>gui/&lt;uid&gt;</code> context. Development installs follow their
installed checkout plan; production checks are clearly labelled prospective when the installed executable
strategy cannot be reconstructed safely.
</p>
<p>
If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and
move the setup earlier in your shell startup chain.
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.
</p>
</article>
+2 -2
View File
@@ -138,8 +138,8 @@
# laptop, phone, tablet — same live sessions
<span class="prompt">$</span> pi-web doctor
✓ login shell can find node >= 22
✓ native service shell can find pi
caller login shell can find node >= 22
✓ native-service plan requirements pass in manager context
✓ ready for persistent agent work</code></pre>
</aside>
</div>
+5 -2
View File
@@ -112,8 +112,10 @@
</ul>
<div class="callout warning">
<strong>Important PATH detail:</strong>
PI WEB services run through your login shell with <code>-lc</code>. Setup that only lives in interactive shell
files or prompt hooks may not be visible to services. Run <code>pi-web doctor</code> after installing.
PI WEB services run through a non-interactive login shell with <code>-lc</code>. Setup that only lives in
interactive shell files or prompt hooks may not be visible to the systemd or launchd manager. The installer
probes the exact candidate plan in that manager context before changing config or replacing services; run
<code>pi-web doctor</code> later to repeat plan-specific diagnostics.
</div>
</section>
@@ -134,6 +136,7 @@
<span class="prompt">$</span> pi-web doctor</code></pre>
</div>
<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>
</section>
+30 -1
View File
@@ -2,7 +2,13 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { commandWithVersionCheck, isCliEntrypoint } from "./cli.js";
import {
commandWithVersionCheck,
doctorExitCode,
isCliEntrypoint,
launchdRuntimeDetails,
serviceBackendForPlatform,
} from "./cli.js";
const originalShell = process.env["SHELL"];
@@ -33,6 +39,29 @@ describe("commandWithVersionCheck", () => {
});
});
describe("native-service doctor CLI contracts", () => {
it("uses native services only on supported platforms", () => {
expect(serviceBackendForPlatform("linux")).toEqual({ kind: "systemd", label: "systemd user services" });
expect(serviceBackendForPlatform("darwin")).toEqual({ kind: "launchd", label: "LaunchAgents" });
expect(serviceBackendForPlatform("win32")).toBeUndefined();
});
it("fails doctor for general, native-plan, or node-pty failures", () => {
expect(doctorExitCode(true, true, true)).toBe(0);
expect(doctorExitCode(false, true, true)).toBe(1);
expect(doctorExitCode(true, false, true)).toBe(1);
expect(doctorExitCode(true, true, false)).toBe(1);
});
it("surfaces launchd last exit code 127 in service status", () => {
expect(launchdRuntimeDetails("state = exited\nlast exit code = 127\n")).toEqual({
state: "exited",
detail: "exited (last exit code 127)",
pid: undefined,
});
});
});
describe("isCliEntrypoint", () => {
it("matches direct execution paths", () => {
expect(isCliEntrypoint("/tmp/pi-web-cli.js", "/tmp/pi-web-cli.js")).toBe(true);
+208 -149
View File
@@ -21,8 +21,22 @@ import {
type NativeServiceManagerRef,
type NativeServicePlan,
type NativeServiceShell,
type ProductionNativeServicePlanInput,
} from "./nativeServices/servicePlan.js";
import { createNativeServiceAuthoritativeProbe } from "./nativeServices/serviceProbe.js";
import {
formatNativeServiceDoctorResult,
inferInstalledNativeServiceMode,
inspectInstalledDevelopmentServiceInput,
inspectInstalledProductionServiceContext,
runNativeServiceDoctor,
type InstalledNativeServiceDefinition,
type NativeServiceDoctorReport,
type NativeServiceDoctorTarget,
} from "./nativeServices/serviceDoctor.js";
import {
createNativeServiceAuthoritativeProbe,
nativeServicePrerequisiteShellCheck,
} from "./nativeServices/serviceProbe.js";
import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
@@ -47,16 +61,6 @@ interface ServiceRef extends NativeServiceManagerRef {
id: ServiceId;
}
interface ServiceExecutable {
command: string;
checks: Check[];
}
interface ServiceExecutables {
sessiond: ServiceExecutable;
web: ServiceExecutable;
}
type ServiceHealth = "running" | "stopped" | "not-installed" | "unknown";
interface ServiceRuntimeStatus {
@@ -89,12 +93,16 @@ function platformLabel(): string {
return process.platform;
}
function currentServiceBackend(): ServiceBackend | undefined {
if (process.platform === "linux") return { kind: "systemd", label: "systemd user services" };
if (process.platform === "darwin") return { kind: "launchd", label: "LaunchAgents" };
export function serviceBackendForPlatform(platform: NodeJS.Platform): ServiceBackend | undefined {
if (platform === "linux") return { kind: "systemd", label: "systemd user services" };
if (platform === "darwin") return { kind: "launchd", label: "LaunchAgents" };
return undefined;
}
function currentServiceBackend(): ServiceBackend | undefined {
return serviceBackendForPlatform(process.platform);
}
function requireServiceBackend(command: string): ServiceBackend {
const backend = currentServiceBackend();
if (backend !== undefined) return backend;
@@ -240,56 +248,6 @@ function serviceShellQuote(value: string): string {
return detectServiceShell().name === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
}
function checkSucceeds(command: string[]): boolean {
const [bin, ...args] = command;
return bin !== undefined && capture(bin, args).status === 0;
}
function serviceShellCanFindCommand(command: string, backend: ServiceBackend): boolean {
if (!checkSucceeds(serviceShellCommand(commandCheck(command)))) return false;
if (backend.kind === "systemd") return checkSucceeds(systemdUserServiceShellCommand(commandCheck(command)));
return true;
}
function readableFileCheck(path: string): string {
const quoted = serviceShellQuote(path);
return `test -r ${quoted} && printf '%s\\n' ${quoted}`;
}
function commandExecutable(command: string, backend: ServiceBackend): ServiceExecutable {
const shell = serviceShellLabel();
const checks: Check[] = [[`${shell} can find ${command}`, serviceShellCommand(commandCheck(command))]];
if (backend.kind === "systemd") {
checks.push([`systemd user ${shell} can find ${command}`, systemdUserServiceShellCommand(commandCheck(command))]);
}
return { command, checks };
}
function bundledExecutable(command: string, entrypointPath: string, backend: ServiceBackend): ServiceExecutable {
const shell = serviceShellLabel();
const check = readableFileCheck(entrypointPath);
const checks: Check[] = [[`${shell} can access bundled ${command} entrypoint`, serviceShellCommand(check)]];
if (backend.kind === "systemd") {
checks.push([`systemd user ${shell} can access bundled ${command} entrypoint`, systemdUserServiceShellCommand(check)]);
}
return { command: `node ${serviceShellQuote(entrypointPath)}`, checks };
}
function serviceExecutable(envName: "PI_WEB_SERVER_EXEC" | "PI_WEB_SESSIOND_EXEC", command: string, entrypointPath: string, backend: ServiceBackend): ServiceExecutable {
const configured = process.env[envName]?.trim();
if (configured !== undefined && configured !== "") return { command: configured, checks: [] };
if (serviceShellCanFindCommand(command, backend)) return commandExecutable(command, backend);
if (existsSync(entrypointPath)) return bundledExecutable(command, entrypointPath, backend);
return commandExecutable(command, backend);
}
function resolveServiceExecutables(backend: ServiceBackend): ServiceExecutables {
return {
sessiond: serviceExecutable("PI_WEB_SESSIOND_EXEC", "pi-web-sessiond", packageEntrypointPath("sessiond"), backend),
web: serviceExecutable("PI_WEB_SERVER_EXEC", "pi-web-server", packageEntrypointPath("server"), backend),
};
}
function describeServiceShell(): string {
const shell = detectServiceShell();
if (shell.source === "fallback") {
@@ -556,6 +514,16 @@ function parseLaunchdField(output: string, field: string): string | undefined {
return match?.[1]?.trim();
}
export function launchdRuntimeDetails(output: string): { state: string; detail: string; pid: string | undefined } {
const state = parseLaunchdField(output, "state") ?? "unknown";
const pid = parseLaunchdField(output, "pid");
const lastExitCode = parseLaunchdField(output, "last exit code");
const detail = state === "running"
? "running"
: lastExitCode === undefined ? state : `${state} (last exit code ${lastExitCode})`;
return { state, detail, pid };
}
function launchdRuntimeStatus(backend: ServiceBackend, ref: ServiceRef): ServiceRuntimeStatus {
const target = launchdServiceTarget(ref);
const filePath = serviceFilePath(backend, ref);
@@ -566,10 +534,9 @@ function launchdRuntimeStatus(backend: ServiceBackend, ref: ServiceRef): Service
return makeServiceRuntimeStatus(ref, "stopped", firstOutputLine(result.stderr, result.stdout) ?? "not loaded", target, filePath);
}
const state = parseLaunchdField(result.stdout, "state") ?? "unknown";
const pid = parseLaunchdField(result.stdout, "pid");
const health: ServiceHealth = state === "running" ? "running" : state === "unknown" ? "unknown" : "stopped";
return makeServiceRuntimeStatus(ref, health, state === "running" ? "running" : state, target, filePath, pid);
const details = launchdRuntimeDetails(result.stdout);
const health: ServiceHealth = details.state === "running" ? "running" : details.state === "unknown" ? "unknown" : "stopped";
return makeServiceRuntimeStatus(ref, health, details.detail, target, filePath, details.pid);
}
function runtimeStatus(backend: ServiceBackend, ref: ServiceRef): ServiceRuntimeStatus {
@@ -598,52 +565,47 @@ function printServiceStatusReport(backend: ServiceBackend): boolean {
return statuses.every((status) => status.health === "running");
}
function backendAvailabilityChecks(backend: ServiceBackend): Check[] {
if (backend.kind === "systemd") return [["systemctl --user", ["systemctl", "--user", "--version"]]];
return [[`launchctl ${launchdDomain()}`, ["launchctl", "print", launchdDomain()]]];
}
function baseShellChecks(backend: ServiceBackend): Check[] {
const shell = serviceShellLabel();
const checks: Check[] = [[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())]];
if (backend.kind === "systemd") checks.push([`systemd user ${shell} can find node >= 22`, systemdUserServiceShellCommand(nodeVersionCheck())]);
return checks;
}
function configuredServiceCommand(name: "PI_WEB_SERVER_EXEC" | "PI_WEB_SESSIOND_EXEC"): string | undefined {
const value = process.env[name];
return value === undefined || value.trim() === "" ? undefined : value;
}
function productionNativeServicePlanInput(
backend: ServiceBackend,
shell: NativeServiceShell,
environment: Readonly<Record<string, string>>,
): ProductionNativeServicePlanInput {
return {
backend,
shell,
environment,
executables: {
sessiond: {
configuredCommand: configuredServiceCommand("PI_WEB_SESSIOND_EXEC"),
namedCommand: "pi-web-sessiond",
bundledEntrypointPath: packageEntrypointPath("sessiond"),
},
web: {
configuredCommand: configuredServiceCommand("PI_WEB_SERVER_EXEC"),
namedCommand: "pi-web-server",
bundledEntrypointPath: packageEntrypointPath("server"),
},
},
};
}
function nativeServiceInstallCandidate(
options: InstallOptions,
backend: ServiceBackend,
configPath: string,
devRoot: string | undefined,
): NativeServiceInstallCandidate {
const common = {
backend,
shell: detectServiceShell(),
environment: configEnvironment(options, configPath),
};
const shell = detectServiceShell();
const environment = configEnvironment(options, configPath);
if (options.mode === "production") {
return {
mode: "production",
input: {
...common,
executables: {
sessiond: {
configuredCommand: configuredServiceCommand("PI_WEB_SESSIOND_EXEC"),
namedCommand: "pi-web-sessiond",
bundledEntrypointPath: packageEntrypointPath("sessiond"),
},
web: {
configuredCommand: configuredServiceCommand("PI_WEB_SERVER_EXEC"),
namedCommand: "pi-web-server",
bundledEntrypointPath: packageEntrypointPath("server"),
},
},
},
input: productionNativeServicePlanInput(backend, shell, environment),
};
}
@@ -651,7 +613,9 @@ function nativeServiceInstallCandidate(
return {
mode: "development",
input: {
...common,
backend,
shell,
environment,
workingDirectory: root,
packageJsonPath: join(root, "package.json"),
},
@@ -787,18 +751,6 @@ function serviceShellLabel(): string {
return `${detectServiceShell().name} -lc`;
}
function systemdUserServiceShellCommand(command: string, cwd?: string): string[] {
return [
"systemd-run",
"--user",
"--wait",
"--collect",
"--pipe",
"--quiet",
...serviceShellCommand(command, cwd),
];
}
function commandCheck(command: string): string {
return `command -v ${command}`;
}
@@ -818,29 +770,13 @@ function nodeVersionCheck(): string {
].join(" && ");
}
function doctorChecks(): Check[] {
function generalDoctorChecks(): Check[] {
const shell = serviceShellLabel();
const backend = currentServiceBackend();
if (backend === undefined) {
return [
[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
];
}
const checks: Check[] = [
...backendAvailabilityChecks(backend),
...baseShellChecks(backend),
[`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
return [
[`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`Caller login ${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
];
const executables = resolveServiceExecutables(backend);
checks.push(...executables.web.checks, ...executables.sessiond.checks);
if (backend.kind === "systemd") {
checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandWithVersionCheck("pi"))]);
}
return checks;
}
function runChecks(checks: Check[]): boolean {
@@ -867,10 +803,7 @@ function printCheckOutput(output: string): void {
function optionalDoctorChecks(): Check[] {
const shell = serviceShellLabel();
const backend = currentServiceBackend();
const checks: Check[] = [[`${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]];
if (backend?.kind === "systemd") checks.push([`systemd user ${shell} can find optional ripgrep (rg)`, systemdUserServiceShellCommand(commandCheck("rg"))]);
return checks;
return [[`Caller login ${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]];
}
function printOptionalDoctorChecks(): void {
@@ -890,8 +823,115 @@ function printOptionalDoctorChecks(): void {
}
}
function printPathSetupAdvice(): void {
const shell = detectServiceShell();
function installedServiceDefinitions(
backend: ServiceBackend,
ids: readonly ServiceId[],
): InstalledNativeServiceDefinition[] {
return ids.map((id) => ({
id,
contents: readFileSync(serviceFilePath(backend, serviceRefs[id]), "utf8"),
}));
}
function nativeServiceDoctorTarget(backend: ServiceBackend): NativeServiceDoctorTarget {
const ids = installedServiceIds(backend);
const mode = inferInstalledNativeServiceMode(ids);
if (mode === "ambiguous") {
return {
kind: "inspection-failure",
message: `installed service IDs do not identify one mode (${[...ids].join(", ") || "none"}).`,
};
}
if (mode === "none") {
return {
kind: "prospective-production",
input: productionNativeServicePlanInput(backend, detectServiceShell(), {}),
reason: "no installed service strategy is available",
};
}
const expectedIds = mode === "production"
? productionNativeServiceIds
: (["sessiond", "uiDev"] as const);
const missingId = expectedIds.find((id) => !ids.has(id));
if (missingId !== undefined) {
return {
kind: "inspection-failure",
message: `installed ${mode} service set is incomplete; ${missingId} is missing.`,
};
}
let definitions: InstalledNativeServiceDefinition[];
try {
definitions = installedServiceDefinitions(
backend,
expectedIds,
);
} catch (error: unknown) {
return {
kind: "inspection-failure",
message: error instanceof Error ? error.message : String(error),
};
}
if (mode === "development") {
const inspection = inspectInstalledDevelopmentServiceInput(backend, definitions);
return inspection.ok
? { kind: "installed-development", input: inspection.value }
: { kind: "inspection-failure", message: inspection.message };
}
const inspection = inspectInstalledProductionServiceContext(backend, definitions);
return inspection.ok
? {
kind: "prospective-production",
input: productionNativeServicePlanInput(backend, inspection.value.shell, inspection.value.environment),
reason: "installed executable strategy is not recorded",
}
: { kind: "inspection-failure", message: inspection.message };
}
async function printNativeServiceDoctorChecks(backend: ServiceBackend): Promise<NativeServiceDoctorReport> {
const result = await runNativeServiceDoctor(nativeServiceDoctorTarget(backend), {
probe: createNativeServiceAuthoritativeProbe(),
fileExists: existsSync,
});
const report = formatNativeServiceDoctorResult(result);
for (const line of report.lines) console.log(line);
printCallerContextComparisons(report);
return report;
}
function printCallerContextComparisons(report: NativeServiceDoctorReport): void {
if (report.plan === null || report.failedPrerequisites.length === 0) return;
const seen = new Set<string>();
for (const prerequisite of report.failedPrerequisites) {
if (seen.has(prerequisite.id)) continue;
seen.add(prerequisite.id);
const service = report.plan.services.find((candidate) => candidate.prerequisites.some((item) => item.id === prerequisite.id));
const command = nativeServicePrerequisiteShellCheck(report.plan.shell.name, prerequisite);
const result = captureServiceShell(report.plan.shell, command, service?.workingDirectory ?? null);
console.log(
` Caller-invoked ${report.plan.shell.name} -lc ${result.status === 0 ? "satisfies" : "also does not satisfy"} ${prerequisite.description}; the service-manager result is authoritative.`,
);
}
}
function captureServiceShell(
shell: NativeServiceShell,
command: string,
workingDirectory: string | null,
): { status: number; stdout: string; stderr: string } {
const fullCommand = workingDirectory === null
? command
: `cd ${shellQuoteFor(shell.name, workingDirectory)} && ${command}`;
return capture("/usr/bin/env", [shell.executable, "-lc", fullCommand]);
}
function shellQuoteFor(shell: NativeServiceShell["name"], value: string): string {
return shell === "fish" ? fishSingleQuote(value) : shellSingleQuote(value);
}
function printPathSetupAdvice(shell: NativeServiceShell = detectServiceShell()): void {
console.log("\nPATH setup advice:");
if (shell.name === "bash") {
console.log(" Detected bash. Put PATH setup for node/version managers/tools in ~/.bash_profile or ~/.profile.");
@@ -906,21 +946,36 @@ function printPathSetupAdvice(): void {
}
}
export function doctorExitCode(
generalReadinessOk: boolean,
nativeServicePlanOk: boolean,
nodePtySpawnHelperOk: boolean,
): 0 | 1 {
return generalReadinessOk && nativeServicePlanOk && nodePtySpawnHelperOk ? 0 : 1;
}
async function doctor(): Promise<void> {
const backend = currentServiceBackend();
console.log(`Platform: ${platformLabel()}`);
console.log(`Service backend: ${backend?.label ?? "manual run only"}`);
console.log(`Service shell: ${describeServiceShell()}`);
if (backend === undefined) {
console.log(`- Native user service checks skipped on ${platformLabel()}`);
console.log(`- Native user service plan checks skipped on ${platformLabel()}; no native-service drift is reported.`);
}
console.log("");
await printPiWebVersionReport();
console.log("\nDoctor checks:");
const ok = runChecks(doctorChecks());
console.log("\nGeneral login-shell readiness (separate from native-service requirements):");
const generalReadinessOk = runChecks(generalDoctorChecks());
printOptionalDoctorChecks();
const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
let nativeServiceReport: NativeServiceDoctorReport | null = null;
if (backend !== undefined) {
console.log("\nNative service plan checks (service-manager context):");
nativeServiceReport = await printNativeServiceDoctorChecks(backend);
}
if (supportsSystemdUserServices()) {
const linger = isLingerEnabled();
if (linger === true) {
@@ -938,17 +993,21 @@ async function doctor(): Promise<void> {
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.");
if (backend?.kind === "systemd") console.log("If a bundled entrypoint is not accessible, reinstall or update the PI WEB package.");
printPathSetupAdvice();
const nativeServicePlanOk = nativeServiceReport?.ok ?? true;
const pathFailure = !generalReadinessOk || nativeServiceReport?.failureKind === "requirements";
if (pathFailure) {
console.log("\nIf a command works in your terminal but fails in the service-manager check, compare the caller and manager contexts above.");
const adviceShell = nativeServiceReport?.failureKind === "requirements" && nativeServiceReport.plan !== null
? nativeServiceReport.plan.shell
: detectServiceShell();
printPathSetupAdvice(adviceShell);
}
if (ok && backend === undefined) {
if (generalReadinessOk && backend === undefined) {
console.log(`\n${manualRunAdvice()}`);
}
if (!ok || !nodePtySpawnHelperOk) process.exitCode = 1;
if (doctorExitCode(generalReadinessOk, nativeServicePlanOk, nodePtySpawnHelperOk) !== 0) process.exitCode = 1;
}
function printNodePtyDarwinSpawnHelperCheck(): boolean {
+238
View File
@@ -0,0 +1,238 @@
import { describe, expect, it } from "vitest";
import {
formatNativeServiceDoctorResult,
inferInstalledNativeServiceMode,
inspectInstalledDevelopmentServiceInput,
inspectInstalledProductionServiceContext,
runNativeServiceDoctor,
type InstalledNativeServiceDefinition,
type NativeServiceDoctorTarget,
} from "./serviceDoctor.js";
import {
createDevelopmentNativeServicePlan,
type NativeServiceAuthoritativeProbe,
type NativeServicePlan,
type ProductionNativeServicePlanInput,
} from "./servicePlan.js";
import { renderLaunchdPlist, renderSystemdUnit } from "./serviceRendering.js";
const shell = {
name: "zsh",
executable: "/bin/zsh",
source: "detected",
detectedExecutable: "/bin/zsh",
} as const;
function productionInput(configured = false): ProductionNativeServicePlanInput {
return {
backend: { kind: "systemd", label: "systemd user services" },
shell,
environment: { PI_WEB_CONFIG: "/home/user/config.json" },
executables: {
sessiond: {
configuredCommand: configured ? "custom sessiond --flag" : undefined,
namedCommand: "pi-web-sessiond",
bundledEntrypointPath: "/package/sessiond.js",
},
web: {
configuredCommand: configured ? "custom web --flag" : undefined,
namedCommand: "pi-web-server",
bundledEntrypointPath: "/package/server.js",
},
},
};
}
function probeWithStatus(status: "satisfied" | "unsatisfied"): NativeServiceAuthoritativeProbe {
return {
run: (request) => Promise.resolve({
kind: "completed",
outcomes: request.prerequisites.map((prerequisite) => ({
prerequisiteId: prerequisite.id,
status,
detail: status === "satisfied" ? null : `${prerequisite.id} missing in manager context`,
})),
}),
};
}
function developmentPlan(kind: "systemd" | "launchd"): NativeServicePlan {
return createDevelopmentNativeServicePlan({
backend: { kind, label: kind },
shell,
environment: { PI_WEB_CONFIG: "/home/user/config & dev.json" },
workingDirectory: "/checkout with space",
packageJsonPath: "/checkout with space/package.json",
});
}
function renderedDefinitions(plan: NativeServicePlan): InstalledNativeServiceDefinition[] {
return plan.services.map((service) => ({
id: service.id,
contents: plan.backend.kind === "systemd"
? renderSystemdUnit(plan, service)
: renderLaunchdPlist(plan, service, "/tmp/logs"),
}));
}
describe("installed native-service mode and definition inspection", () => {
it("infers production, development, absent, and ambiguous service sets", () => {
expect(inferInstalledNativeServiceMode(new Set())).toBe("none");
expect(inferInstalledNativeServiceMode(new Set(["sessiond", "web"]))).toBe("production");
expect(inferInstalledNativeServiceMode(new Set(["sessiond", "uiDev"]))).toBe("development");
expect(inferInstalledNativeServiceMode(new Set(["web", "uiDev"]))).toBe("ambiguous");
expect(inferInstalledNativeServiceMode(new Set(["sessiond"]))).toBe("ambiguous");
});
it.each(["systemd", "launchd"] as const)("reconstructs an exact installed development input from %s definitions", (kind) => {
const plan = developmentPlan(kind);
expect(inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan))).toEqual({
ok: true,
value: {
backend: plan.backend,
shell,
environment: { PI_WEB_CONFIG: "/home/user/config & dev.json" },
workingDirectory: "/checkout with space",
packageJsonPath: "/checkout with space/package.json",
},
});
});
it("inspects legacy systemd definitions without /usr/bin/env or quoted working directories", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents
.replace("ExecStart=/usr/bin/env ", "ExecStart=")
.replace('WorkingDirectory="/checkout with space"', "WorkingDirectory=/checkout with space"),
}));
expect(inspectInstalledDevelopmentServiceInput(plan.backend, definitions)).toMatchObject({
ok: true,
value: { workingDirectory: "/checkout with space" },
});
});
it("rejects a modified development command rather than claiming to check the installed plan", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan);
const firstDefinition = definitions[0];
if (firstDefinition === undefined) throw new Error("Expected a rendered service definition");
definitions[0] = {
...firstDefinition,
contents: firstDefinition.contents.replace("exec npm run start:sessiond", "exec npm run something-else"),
};
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected development inspection to fail");
expect(inspection.message).toContain("does not match the canonical development plan");
});
it("recovers production shell and environment while leaving executable strategy prospective", () => {
const plan = developmentPlan("launchd");
const firstService = plan.services[0];
if (firstService === undefined) throw new Error("Expected a development service");
const productionService = { ...firstService, workingDirectory: null };
const productionPlan: NativeServicePlan = { ...plan, mode: "production", services: [productionService] };
const productionLike = [{
id: "sessiond" as const,
contents: renderLaunchdPlist(productionPlan, productionService, "/tmp/logs"),
}];
expect(inspectInstalledProductionServiceContext(plan.backend, productionLike)).toEqual({
ok: true,
value: {
shell,
environment: { PI_WEB_CONFIG: "/home/user/config & dev.json" },
},
});
});
});
describe("native-service doctor planning and reporting", () => {
it("validates installed development requirements without production binary checks", async () => {
const plan = developmentPlan("launchd");
const inspected = inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan));
if (!inspected.ok) throw new Error(inspected.message);
const result = await runNativeServiceDoctor(
{ kind: "installed-development", input: inspected.value },
{ probe: probeWithStatus("satisfied"), fileExists: () => false },
);
const report = formatNativeServiceDoctorResult(result);
expect(report.ok).toBe(true);
expect(report.lines).toContain("Installed development native-service plan:");
expect(report.plan?.services.flatMap((service) => service.prerequisites)).not.toEqual(
expect.arrayContaining([expect.objectContaining({ command: "pi-web-server" })]),
);
});
it("labels a production check as prospective and reports manager-context requirements", async () => {
const target: NativeServiceDoctorTarget = {
kind: "prospective-production",
input: productionInput(),
reason: "installed executable strategy is not recorded",
};
const result = await runNativeServiceDoctor(target, {
probe: probeWithStatus("unsatisfied"),
fileExists: () => true,
});
const report = formatNativeServiceDoctorResult(result);
expect(report.ok).toBe(false);
expect(report.failureKind).toBe("requirements");
expect(report.lines[0]).toContain("Prospective production native-service plan");
expect(report.lines.join("\n")).toContain("Native service requirement failed");
expect(report.failedPrerequisites).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "node-version" }),
expect.objectContaining({ kind: "readable-file" }),
]));
});
it("preserves configured overrides as unverified and does not probe arbitrary commands", async () => {
let calls = 0;
const result = await runNativeServiceDoctor(
{ kind: "prospective-production", input: productionInput(true), reason: "current configured overrides" },
{
probe: { run: () => { calls += 1; return Promise.resolve({ kind: "completed", outcomes: [] }); } },
fileExists: () => false,
},
);
const report = formatNativeServiceDoctorResult(result);
expect(calls).toBe(0);
expect(report.ok).toBe(true);
expect(report.lines.join("\n")).toContain("does not execute arbitrary configured commands");
});
it.each(["manager", "timeout", "malformed-output", "cleanup"] as const)(
"distinguishes %s infrastructure failures from PATH requirement drift",
async (reason) => {
const result = await runNativeServiceDoctor(
{ kind: "prospective-production", input: productionInput(), reason: "no installed services" },
{
probe: { run: () => Promise.resolve({ kind: "infrastructure-failure", reason, message: `${reason} failure` }) },
fileExists: () => true,
},
);
const report = formatNativeServiceDoctorResult(result);
expect(report.ok).toBe(false);
expect(report.failureKind).toBe("infrastructure");
expect(report.lines.join("\n")).toContain(`infrastructure failure (${reason})`);
expect(report.lines.join("\n")).toContain("not proof of a PATH mismatch");
},
);
it("makes mixed or malformed installed definitions a failing inspection result", async () => {
const result = await runNativeServiceDoctor(
{ kind: "inspection-failure", message: "production and development service IDs are both installed" },
{ probe: probeWithStatus("satisfied"), fileExists: () => true },
);
const report = formatNativeServiceDoctorResult(result);
expect(report).toMatchObject({ ok: false, failureKind: "inspection" });
expect(report.lines.join("\n")).toContain("could not be inspected");
});
});
+424
View File
@@ -0,0 +1,424 @@
import { basename, join } from "node:path";
import {
createDevelopmentNativeServicePlan,
resolveProductionNativeServicePlan,
validateNativeServicePlan,
type DevelopmentNativeServicePlanInput,
type NativeServiceBackend,
type NativeServiceId,
type NativeServicePlan,
type NativeServicePlanDependencies,
type NativeServicePlanFailure,
type NativeServicePlanValidationFailure,
type NativeServicePrerequisite,
type NativeServiceShell,
type ProductionNativeServicePlanInput,
} from "./servicePlan.js";
export type InstalledNativeServiceMode = "none" | "production" | "development" | "ambiguous";
export interface InstalledNativeServiceDefinition {
id: NativeServiceId;
contents: string;
}
export interface InstalledNativeServiceContext {
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
}
export type InstalledNativeServiceInspection<T> =
| { ok: true; value: T }
| { ok: false; message: string };
export type NativeServiceDoctorTarget =
| {
kind: "installed-development";
input: DevelopmentNativeServicePlanInput;
}
| {
kind: "prospective-production";
input: ProductionNativeServicePlanInput;
reason: string;
}
| {
kind: "inspection-failure";
message: string;
};
interface NativeServiceDoctorScope {
kind: "installed-development" | "prospective-production";
reason: string | null;
}
export type NativeServiceDoctorResult =
| {
kind: "inspection-failure";
message: string;
}
| {
kind: "plan-resolution-failure";
scope: NativeServiceDoctorScope;
failures: readonly NativeServicePlanFailure[];
}
| {
kind: "plan-validation";
scope: NativeServiceDoctorScope;
plan: NativeServicePlan;
validation: { ok: true } | { ok: false; failures: readonly NativeServicePlanValidationFailure[] };
};
export interface NativeServiceDoctorReport {
ok: boolean;
failureKind: "none" | "requirements" | "infrastructure" | "inspection";
lines: readonly string[];
plan: NativeServicePlan | null;
failedPrerequisites: readonly NativeServicePrerequisite[];
}
interface ParsedServiceDefinition {
id: NativeServiceId;
shell: NativeServiceShell;
environment: Readonly<Record<string, string>>;
workingDirectory: string | null;
shellCommand: string;
}
export function inferInstalledNativeServiceMode(serviceIds: ReadonlySet<NativeServiceId>): InstalledNativeServiceMode {
if (serviceIds.size === 0) return "none";
const hasProductionWeb = serviceIds.has("web");
const hasDevelopmentUi = serviceIds.has("uiDev");
if (hasProductionWeb && !hasDevelopmentUi) return "production";
if (hasDevelopmentUi && !hasProductionWeb) return "development";
return "ambiguous";
}
export function inspectInstalledProductionServiceContext(
backend: NativeServiceBackend,
definitions: readonly InstalledNativeServiceDefinition[],
): InstalledNativeServiceInspection<InstalledNativeServiceContext> {
const parsed = parseConsistentDefinitions(backend, definitions);
if (!parsed.ok) return parsed;
const withWorkingDirectory = parsed.value.find((definition) => definition.workingDirectory !== null);
if (withWorkingDirectory !== undefined) {
return {
ok: false,
message: `Installed production service ${withWorkingDirectory.id} unexpectedly has working directory ${withWorkingDirectory.workingDirectory ?? ""}.`,
};
}
return {
ok: true,
value: {
shell: parsed.value[0]?.shell ?? impossibleMissingDefinition(),
environment: parsed.value[0]?.environment ?? impossibleMissingDefinition(),
},
};
}
export function inspectInstalledDevelopmentServiceInput(
backend: NativeServiceBackend,
definitions: readonly InstalledNativeServiceDefinition[],
): InstalledNativeServiceInspection<DevelopmentNativeServicePlanInput> {
const parsed = parseConsistentDefinitions(backend, definitions);
if (!parsed.ok) return parsed;
const first = parsed.value[0] ?? impossibleMissingDefinition();
if (first.workingDirectory === null) {
return { ok: false, message: "Installed development services do not declare a working directory." };
}
const input: DevelopmentNativeServicePlanInput = {
backend,
shell: first.shell,
environment: first.environment,
workingDirectory: first.workingDirectory,
packageJsonPath: join(first.workingDirectory, "package.json"),
};
const expectedPlan = createDevelopmentNativeServicePlan(input);
for (const definition of parsed.value) {
const expected = expectedPlan.services.find((service) => service.id === definition.id);
if (expected?.shellCommand !== definition.shellCommand) {
return {
ok: false,
message: `Installed ${definition.id} service command does not match the canonical development plan.`,
};
}
}
return { ok: true, value: input };
}
export async function runNativeServiceDoctor(
target: NativeServiceDoctorTarget,
dependencies: NativeServicePlanDependencies,
): Promise<NativeServiceDoctorResult> {
if (target.kind === "inspection-failure") return target;
const scope: NativeServiceDoctorScope = target.kind === "installed-development"
? { kind: target.kind, reason: null }
: { kind: target.kind, reason: target.reason };
let plan: NativeServicePlan;
if (target.kind === "installed-development") {
plan = createDevelopmentNativeServicePlan(target.input);
} else {
const resolution = await resolveProductionNativeServicePlan(target.input, dependencies);
if (!resolution.ok) {
return { kind: "plan-resolution-failure", scope, failures: resolution.failures };
}
plan = resolution.plan;
}
const validation = await validateNativeServicePlan(plan, dependencies.probe);
return { kind: "plan-validation", scope, plan, validation };
}
export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResult): NativeServiceDoctorReport {
if (result.kind === "inspection-failure") {
return {
ok: false,
failureKind: "inspection",
lines: [
`✗ Installed native-service plan could not be inspected: ${result.message}`,
" Run `pi-web install` or `pi-web install --dev` to replace mixed, partial, or outdated service definitions.",
],
plan: null,
failedPrerequisites: [],
};
}
const lines = [scopeHeading(result.scope)];
if (result.kind === "plan-resolution-failure") {
let infrastructure = false;
for (const failure of result.failures) {
if (failure.kind === "probe-infrastructure") {
infrastructure = true;
lines.push(`✗ Native service probe infrastructure failure (${failure.reason}): ${failure.message}`);
} else if (failure.kind === "entrypoint-inspection-failure") {
infrastructure = true;
lines.push(`✗ Could not inspect bundled ${failure.serviceId} entrypoint ${failure.entrypointPath}: ${failure.message}`);
} else {
lines.push(`${failure.namedCommand} is unavailable to the native service manager, and bundled entrypoint ${failure.bundledEntrypointPath} is missing.`);
if (failure.namedCommandFailure !== null) lines.push(` ${failure.namedCommandFailure}`);
}
}
if (infrastructure) lines.push(" This infrastructure failure is not proof of a PATH mismatch.");
return {
ok: false,
failureKind: infrastructure ? "infrastructure" : "requirements",
lines,
plan: null,
failedPrerequisites: [],
};
}
const configuredOverrides = result.plan.services.filter((service) => service.strategy.kind === "configured-override");
for (const service of configuredOverrides) {
lines.push(`! ${service.description} uses a configured command override; doctor does not execute arbitrary configured commands.`);
}
if (result.validation.ok) {
lines.push("✓ All verifiable native-service plan requirements are satisfied in the service-manager context.");
return { ok: true, failureKind: "none", lines, plan: result.plan, failedPrerequisites: [] };
}
const failedPrerequisites: NativeServicePrerequisite[] = [];
let infrastructure = false;
for (const failure of result.validation.failures) {
if (failure.kind === "probe-infrastructure") {
infrastructure = true;
lines.push(`✗ Native service probe infrastructure failure (${failure.reason}): ${failure.message}`);
} else {
failedPrerequisites.push(failure.prerequisite);
lines.push(`✗ Native service requirement failed: ${failure.prerequisite.description}`);
if (failure.detail !== null && failure.detail !== failure.prerequisite.description) lines.push(` ${failure.detail}`);
}
}
if (infrastructure) lines.push(" This infrastructure failure is not proof of a PATH mismatch.");
return {
ok: false,
failureKind: infrastructure ? "infrastructure" : "requirements",
lines,
plan: result.plan,
failedPrerequisites,
};
}
function scopeHeading(scope: NativeServiceDoctorScope): string {
if (scope.kind === "installed-development") return "Installed development native-service plan:";
return `Prospective production native-service plan (${scope.reason ?? "installed strategy is unknown"}):`;
}
function parseConsistentDefinitions(
backend: NativeServiceBackend,
definitions: readonly InstalledNativeServiceDefinition[],
): InstalledNativeServiceInspection<readonly ParsedServiceDefinition[]> {
if (definitions.length === 0) return { ok: false, message: "No installed service definitions were provided." };
const parsed: ParsedServiceDefinition[] = [];
for (const definition of definitions) {
const result = backend.kind === "systemd"
? parseSystemdDefinition(definition)
: parseLaunchdDefinition(definition);
if (!result.ok) return result;
parsed.push(result.value);
}
const first = parsed[0] ?? impossibleMissingDefinition();
for (const definition of parsed.slice(1)) {
if (definition.shell.executable !== first.shell.executable) {
return { ok: false, message: "Installed service definitions use different login shells." };
}
if (!recordsEqual(definition.environment, first.environment)) {
return { ok: false, message: "Installed service definitions use different environments." };
}
if (definition.workingDirectory !== first.workingDirectory) {
return { ok: false, message: "Installed service definitions use different working directories." };
}
}
return { ok: true, value: parsed };
}
function parseSystemdDefinition(
definition: InstalledNativeServiceDefinition,
): InstalledNativeServiceInspection<ParsedServiceDefinition> {
const execStart = /^ExecStart=(?:\/usr\/bin\/env )?(.+?) -lc (.+)$/mu.exec(definition.contents);
if (execStart?.[1] === undefined || execStart[2] === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized ExecStart.` };
}
const shell = installedShell(execStart[1]);
if (!shell.ok) return shell;
const shellCommand = parseShellQuotedValue(shell.value.name, execStart[2]);
if (shellCommand === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized shell command.` };
}
const environment: Record<string, string> = {};
for (const match of definition.contents.matchAll(/^Environment="((?:\\.|[^"])*)"$/gmu)) {
const assignment = systemdUnescape(match[1] ?? "");
const separator = assignment.indexOf("=");
if (separator <= 0) return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed environment entry.` };
environment[assignment.slice(0, separator)] = assignment.slice(separator + 1);
}
const workingDirectoryMatch = /^WorkingDirectory=(.+)$/mu.exec(definition.contents);
const workingDirectory = workingDirectoryMatch?.[1] === undefined
? null
: parseSystemdValue(workingDirectoryMatch[1]);
if (workingDirectoryMatch !== null && workingDirectory === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed working directory.` };
}
return {
ok: true,
value: { id: definition.id, shell: shell.value, environment, workingDirectory: workingDirectory ?? null, shellCommand },
};
}
function parseLaunchdDefinition(
definition: InstalledNativeServiceDefinition,
): InstalledNativeServiceInspection<ParsedServiceDefinition> {
const argumentsBlock = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/u.exec(definition.contents)?.[1];
if (argumentsBlock === undefined) {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has no ProgramArguments array.` };
}
const arguments_ = [...argumentsBlock.matchAll(/<string>([\s\S]*?)<\/string>/gu)].map((match) => xmlUnescape(match[1] ?? ""));
if (arguments_.length !== 4 || arguments_[0] !== "/usr/bin/env" || arguments_[2] !== "-lc") {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has unrecognized ProgramArguments.` };
}
const shell = installedShell(arguments_[1] ?? "");
if (!shell.ok) return shell;
const environment: Record<string, string> = {};
const environmentBlock = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/u.exec(definition.contents)?.[1];
if (environmentBlock !== undefined) {
for (const match of environmentBlock.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/gu)) {
environment[xmlUnescape(match[1] ?? "")] = xmlUnescape(match[2] ?? "");
}
}
const workingDirectory = launchdString(definition.contents, "WorkingDirectory");
return {
ok: true,
value: {
id: definition.id,
shell: shell.value,
environment,
workingDirectory,
shellCommand: arguments_[3] ?? "",
},
};
}
function installedShell(executable: string): InstalledNativeServiceInspection<NativeServiceShell> {
const name = basename(executable).replace(/^-/, "");
if (name !== "bash" && name !== "zsh" && name !== "fish") {
return { ok: false, message: `Installed service definition uses unsupported login shell ${executable}.` };
}
return {
ok: true,
value: { name, executable, source: "detected", detectedExecutable: executable },
};
}
function parseSystemdValue(value: string): string | undefined {
if (!value.startsWith('"') && !value.endsWith('"')) return value;
if (!value.startsWith('"') || !value.endsWith('"')) return undefined;
return systemdUnescape(value.slice(1, -1));
}
function systemdUnescape(value: string): string {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character === "\\" && index + 1 < value.length) {
result += value[index + 1] ?? "";
index += 1;
} else {
result += character ?? "";
}
}
return result;
}
function parseShellQuotedValue(shell: NativeServiceShell["name"], value: string): string | undefined {
if (!value.startsWith("'") || !value.endsWith("'")) return undefined;
const inner = value.slice(1, -1);
if (shell === "fish") return fishSingleQuoteUnescape(inner);
return inner.replaceAll("'\\''", "'").replaceAll("$$", "$").replaceAll("%%", "%");
}
function fishSingleQuoteUnescape(value: string): string {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character === "\\" && index + 1 < value.length) {
result += value[index + 1] ?? "";
index += 1;
} else {
result += character ?? "";
}
}
return result.replaceAll("$$", "$").replaceAll("%%", "%");
}
function launchdString(contents: string, key: string): string | null {
const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const value = new RegExp(`<key>${escapedKey}<\\/key>\\s*<string>([\\s\\S]*?)<\\/string>`, "u").exec(contents)?.[1];
return value === undefined ? null : xmlUnescape(value);
}
function xmlUnescape(value: string): string {
return value
.replaceAll("&apos;", "'")
.replaceAll("&quot;", '"')
.replaceAll("&gt;", ">")
.replaceAll("&lt;", "<")
.replaceAll("&amp;", "&");
}
function recordsEqual(left: Readonly<Record<string, string>>, right: Readonly<Record<string, string>>): boolean {
const leftEntries = Object.entries(left);
return leftEntries.length === Object.keys(right).length
&& leftEntries.every(([key, value]) => right[key] === value);
}
function impossibleMissingDefinition(): never {
throw new Error("Expected at least one installed native service definition");
}
+93
View File
@@ -102,6 +102,25 @@ describe("systemd authoritative native-service probe", () => {
});
});
it("bounds a hung unit, cleans it up, and reports the timeout", async () => {
const runner = queuedRunner([
{ kind: "timeout", stdout: "", stderr: "" },
completed(0),
completed(0),
]);
const probe = new SystemdNativeServiceProbe({
commandRunner: runner,
createUniqueId: () => "fixed",
commandTimeoutMs: 100,
});
await expect(probe.run(request())).resolves.toMatchObject({
kind: "infrastructure-failure",
reason: "timeout",
});
expect(runner.calls.map(({ command }) => command)).toEqual(["systemd-run", "systemctl", "systemctl"]);
});
it("bounds a hung unit and distinguishes cleanup failure", async () => {
const runner = queuedRunner([
{ kind: "timeout", stdout: "", stderr: "" },
@@ -276,6 +295,80 @@ describe("launchd authoritative native-service probe", () => {
expect(runner.calls).toHaveLength(1);
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("cleans a loaded label after malformed launchctl output", async () => {
const runner = queuedRunner([
completed(0),
completed(0, "pid = 123\n"),
completed(0),
]);
const fileSystem = launchdFileSystem({});
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 505,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
await expect(probe.run(request("launchd"))).resolves.toMatchObject({
kind: "infrastructure-failure",
reason: "malformed-output",
});
expect(runner.calls.at(-1)?.args[0]).toBe("bootout");
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("cleans a loaded label when probe output cannot be read", async () => {
const runner = queuedRunner([
completed(0),
completed(0, "state = not running\nlast exit code = 0\n"),
completed(0),
]);
const fileSystem = launchdFileSystem({});
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 506,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
const result = await probe.run(request("launchd"));
expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "manager" });
expect(result.kind === "infrastructure-failure" && result.message).toContain("Could not read launchd probe output");
expect(runner.calls.at(-1)?.args[0]).toBe("bootout");
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("reports temporary-file cleanup failures", async () => {
const runner = queuedRunner([completed(1, "", "bootstrap denied")]);
const fileSystem = launchdFileSystem({});
fileSystem.removeDirectory.mockRejectedValueOnce(new Error("rm denied"));
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 506,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 20,
pollIntervalMs: 10,
commandTimeoutMs: 100,
});
const result = await probe.run(request("launchd"));
expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "cleanup" });
expect(result.kind === "infrastructure-failure" && result.message).toContain("rm denied");
});
});
describe("probe service definitions", () => {
+2 -2
View File
@@ -365,7 +365,7 @@ function prerequisiteProbeCommand(
outputPrefix: string,
): string {
return prerequisites.map((prerequisite) => {
const check = prerequisiteCheck(shell, prerequisite);
const check = nativeServicePrerequisiteShellCheck(shell, prerequisite);
const encodedId = Buffer.from(prerequisite.id, "utf8").toString("base64");
const satisfied = markerCommand(shell, outputPrefix, encodedId, "satisfied");
const unsatisfied = markerCommand(shell, outputPrefix, encodedId, "unsatisfied");
@@ -373,7 +373,7 @@ function prerequisiteProbeCommand(
}).join("; ") || ":";
}
function prerequisiteCheck(shell: NativeServiceShellName, prerequisite: NativeServicePrerequisite): string {
export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellName, prerequisite: NativeServicePrerequisite): string {
switch (prerequisite.kind) {
case "command-available":
return `command -v ${shellQuote(shell, prerequisite.command)}`;