From 21c58fe6560db74af889610884a29ef9557ea4fc Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 22:39:41 +0200 Subject: [PATCH 01/12] fix: serve plugin SVG assets with correct MIME type --- .changeset/serve-plugin-svg-assets.md | 5 +++++ docs/plugins.md | 10 +++++++++- src/server/app.plugins.test.ts | 5 +++++ src/server/app.testSupport.ts | 9 ++++++++- src/server/piWebPluginService.test.ts | 22 ++++++++++++++++++++++ src/server/piWebPluginService.ts | 15 +++++++++------ 6 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 .changeset/serve-plugin-svg-assets.md diff --git a/.changeset/serve-plugin-svg-assets.md b/.changeset/serve-plugin-svg-assets.md new file mode 100644 index 0000000..a17dea4 --- /dev/null +++ b/.changeset/serve-plugin-svg-assets.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Serve PI WEB plugin SVG assets with a browser-compatible content type and clarify module-relative asset packaging. diff --git a/docs/plugins.md b/docs/plugins.md index dd08ba6..77452eb 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -347,7 +347,15 @@ A plugin can fetch its own static assets with URLs under: /pi-web-plugins// ``` -PI WEB prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, and HTML get appropriate content types; other files are served as octet-stream. +Prefer module-relative asset URLs so they also work for remote machine plugins. For example, a built plugin module can reference an SVG shipped beside it: + +```js +const iconUrl = new URL("./assets/icon.svg", import.meta.url); +``` + +The final installed plugin package must contain `assets/icon.svg` at that path relative to the final built module. PI WEB serves files that already exist in the package; it does not copy a source `public/` directory or apply Vite-style public-directory semantics. Configure the plugin build and package contents to emit or copy the asset into its final module-relative location. + +PI WEB prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, HTML, and SVG files get appropriate content types; unknown file types are served as octet-stream. ## Plugin module shape diff --git a/src/server/app.plugins.test.ts b/src/server/app.plugins.test.ts index 1969335..d4e4d8e 100644 --- a/src/server/app.plugins.test.ts +++ b/src/server/app.plugins.test.ts @@ -24,6 +24,11 @@ describe("buildApp PI WEB plugin routes", () => { expect(assetResponse.headers["content-type"]).toContain("application/javascript"); expect(assetResponse.body).toBe("export default {};"); + const svgResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/assets/icon.svg" }); + expect(svgResponse.statusCode).toBe(200); + expect(svgResponse.headers["content-type"]).toContain("image/svg+xml"); + expect(svgResponse.body).toContain(" Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), - readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined), + readAsset: fakePiWebPluginAsset, }, clientDist: false, logger: false, @@ -123,6 +123,13 @@ export function registerAppTestHooks(): void { }); } +function fakePiWebPluginAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> { + if (pluginId !== "fake") return Promise.resolve(undefined); + if (assetPath === "plugin.js") return Promise.resolve({ content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" }); + if (assetPath === "assets/icon.svg") return Promise.resolve({ content: Buffer.from(''), contentType: "image/svg+xml" }); + return Promise.resolve(undefined); +} + export interface CapturedSessionDaemonRequest { method: string; path: string; diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 733cfe0..7507aa7 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -44,6 +44,28 @@ describe("PiWebPluginService", () => { expect(asset?.content.toString("utf8")).toContain("export default"); }); + it("serves nested SVG assets with a browser-compatible content type", async () => { + const pluginDir = join(tempDir, "plugins", "icons"); + const svg = ''; + await writePlugin(pluginDir, { + packageJson: { piWeb: { plugins: [{ id: "icons", module: "pi-web-plugin.js" }] } }, + files: { + "pi-web-plugin.js": "export default {};", + "assets/icon.svg": svg, + "assets/uppercase.SVG": svg, + "assets/data.bin": "unknown", + }, + }); + + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + + const svgAsset = await service.readAsset("icons", "assets/icon.svg"); + expect(svgAsset?.contentType).toBe("image/svg+xml"); + expect(svgAsset?.content.toString("utf8")).toBe(svg); + await expect(service.readAsset("icons", "assets/uppercase.SVG")).resolves.toMatchObject({ contentType: "image/svg+xml" }); + await expect(service.readAsset("icons", "assets/data.bin")).resolves.toMatchObject({ contentType: "application/octet-stream" }); + }); + it("includes machine-specific preferences in plugin manifests", async () => { await writePlugin(join(tempDir, "plugins", "updates"), { packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } }, diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 563315e..0566153 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readdir, readFile, realpath, stat } from "node:fs/promises"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; @@ -335,11 +335,14 @@ function isWithin(root: string, candidate: string): boolean { } function contentTypeFor(path: string): string { - if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; - if (path.endsWith(".json")) return "application/json; charset=utf-8"; - if (path.endsWith(".css")) return "text/css; charset=utf-8"; - if (path.endsWith(".html")) return "text/html; charset=utf-8"; - return "application/octet-stream"; + switch (extname(path).toLowerCase()) { + case ".js": return "application/javascript; charset=utf-8"; + case ".json": return "application/json; charset=utf-8"; + case ".css": return "text/css; charset=utf-8"; + case ".html": return "text/html; charset=utf-8"; + case ".svg": return "image/svg+xml"; + default: return "application/octet-stream"; + } } function isRecord(value: unknown): value is Record { From f9c0fed5e31064f65187c90f101bbf423d78b451 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 23:07:16 +0200 Subject: [PATCH 02/12] fix: preserve extension-only plugin asset MIME types --- src/server/piWebPluginService.test.ts | 16 ++++++++++++++++ src/server/piWebPluginService.ts | 17 ++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 7507aa7..6b00d34 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -44,6 +44,22 @@ describe("PiWebPluginService", () => { expect(asset?.content.toString("utf8")).toContain("export default"); }); + it("preserves content types for extension-only asset names", async () => { + const pluginDir = join(tempDir, "plugins", "extension-only"); + await writePlugin(pluginDir, { + packageJson: { piWeb: { plugins: [{ id: "extension-only", module: ".js" }] } }, + files: { + ".js": "export default {};", + ".svg": '', + }, + }); + + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + + await expect(service.readAsset("extension-only", ".js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" }); + await expect(service.readAsset("extension-only", ".svg")).resolves.toMatchObject({ contentType: "image/svg+xml" }); + }); + it("serves nested SVG assets with a browser-compatible content type", async () => { const pluginDir = join(tempDir, "plugins", "icons"); const svg = ''; diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 0566153..45938ea 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readdir, readFile, realpath, stat } from "node:fs/promises"; -import { dirname, extname, join, relative, resolve, sep } from "node:path"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; @@ -335,14 +335,13 @@ function isWithin(root: string, candidate: string): boolean { } function contentTypeFor(path: string): string { - switch (extname(path).toLowerCase()) { - case ".js": return "application/javascript; charset=utf-8"; - case ".json": return "application/json; charset=utf-8"; - case ".css": return "text/css; charset=utf-8"; - case ".html": return "text/html; charset=utf-8"; - case ".svg": return "image/svg+xml"; - default: return "application/octet-stream"; - } + const lowerPath = path.toLowerCase(); + if (lowerPath.endsWith(".js")) return "application/javascript; charset=utf-8"; + if (lowerPath.endsWith(".json")) return "application/json; charset=utf-8"; + if (lowerPath.endsWith(".css")) return "text/css; charset=utf-8"; + if (lowerPath.endsWith(".html")) return "text/html; charset=utf-8"; + if (lowerPath.endsWith(".svg")) return "image/svg+xml"; + return "application/octet-stream"; } function isRecord(value: unknown): value is Record { From d72b14f40ab0ac18a518d0d7183671000e980469 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 23:21:43 +0200 Subject: [PATCH 03/12] feat: add manual PI WEB update checks --- .changeset/fresh-machine-update-checks.md | 5 + docs/plugins.html | 7 +- docs/plugins.md | 8 +- pi-web-plugins/updates/pi-web-plugin.test.ts | 52 +++++++ pi-web-plugins/updates/pi-web-plugin.ts | 12 ++ src/client/src/api/clients.test.ts | 46 ++++-- src/client/src/api/clients.ts | 7 +- .../src/api/federatedRouteContract.test.ts | 1 + src/client/src/components/PiWebApp.ts | 22 ++- .../controllers/piWebStatusController.test.ts | 143 ++++++++++++++++++ .../src/controllers/piWebStatusController.ts | 68 +++++++++ src/client/src/plugins/types.ts | 1 + src/plugin-api.ts | 2 + src/server/app.piWebStatus.test.ts | 38 +++++ src/server/app.remoteProxy.test.ts | 17 +++ src/server/app.ts | 14 +- src/server/piWebReleaseLookupCache.test.ts | 80 ++++++++++ src/server/piWebReleaseLookupCache.ts | 57 +++++++ src/server/piWebStatus.test.ts | 33 +++- src/server/piWebStatus.ts | 26 ++-- src/server/piWebStatusCache.test.ts | 41 ++++- src/server/piWebStatusCache.ts | 36 +++-- 22 files changed, 653 insertions(+), 63 deletions(-) create mode 100644 .changeset/fresh-machine-update-checks.md create mode 100644 pi-web-plugins/updates/pi-web-plugin.test.ts create mode 100644 src/client/src/controllers/piWebStatusController.test.ts create mode 100644 src/client/src/controllers/piWebStatusController.ts create mode 100644 src/server/app.piWebStatus.test.ts create mode 100644 src/server/piWebReleaseLookupCache.test.ts create mode 100644 src/server/piWebReleaseLookupCache.ts diff --git a/.changeset/fresh-machine-update-checks.md b/.changeset/fresh-machine-update-checks.md new file mode 100644 index 0000000..3676170 --- /dev/null +++ b/.changeset/fresh-machine-update-checks.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a **Check for PI WEB Updates** action that bypasses cached release data and refreshes update status for the selected local or federated machine. diff --git a/docs/plugins.html b/docs/plugins.html index 89ea47b..543ab89 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -250,11 +250,14 @@ After editing, check the manifest endpoint and browser-console failure cases.Updates

Updates adds a conditional Updates workspace tab with PI WEB update, - restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the - selected machine's plugin copy when machine federation is active. + restart, and installed-service guidance, plus a Check for PI WEB Updates action. It is + built into PI WEB, enabled by default, and uses the selected machine's plugin copy when machine + federation is active.

  • Plugin id: updates
  • +
  • Selected-machine status refreshes every 15 minutes while a browser tab is connected.
  • +
  • Automatic npm release lookups are cached for six hours; the action bypasses the caches and checks immediately.
diff --git a/docs/plugins.md b/docs/plugins.md index 77452eb..9a353df 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -202,9 +202,11 @@ Built-in plugins can be managed from **Settings → PI WEB plugins** or with the ### Updates **Plugin id:** `updates` -**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance. +**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance, plus a **Check for PI WEB Updates** action for the selected machine. -Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → PI WEB plugins** or set: +While a browser tab is connected, PI WEB refreshes the selected machine's status every 15 minutes. npm release lookups are cached on that machine for six hours, so the automatic refresh normally contacts npm at most once in that window. Run **Check for PI WEB Updates** from the action palette to bypass both caches and check immediately. Operator settings that skip remote version checks, such as `PI_WEB_OFFLINE`, are still respected. + +Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab and action only appear for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → PI WEB plugins** or set: ```json { @@ -474,6 +476,7 @@ interface PluginRuntimeContext { openTerminal: (options?: { terminalId?: string }) => void; refreshFiles: () => void | Promise; refreshGit: () => void | Promise; + checkForPiWebUpdates?: () => void | Promise; startSession: () => void | Promise; archiveSession: () => void | Promise; stopActiveWork: () => void | Promise; @@ -488,6 +491,7 @@ Notes: - `enabled` is evaluated when the action palette asks for actions. - `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`. - `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal. +- `checkForPiWebUpdates()` forces a fresh update check on the selected machine and refreshes `state.piWebStatus`. It is optional so plugins remain compatible with older PI WEB hosts. - Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear. ### Prompt editor API diff --git a/pi-web-plugins/updates/pi-web-plugin.test.ts b/pi-web-plugins/updates/pi-web-plugin.test.ts new file mode 100644 index 0000000..b6638b4 --- /dev/null +++ b/pi-web-plugins/updates/pi-web-plugin.test.ts @@ -0,0 +1,52 @@ +import { html, svg } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginRuntimeContext } from "@jmfederico/pi-web/plugin-api"; +import plugin from "./pi-web-plugin.js"; + +describe("Updates plugin actions", () => { + it("forces an update check through the host runtime context", async () => { + const action = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }).contributions.actions?.find((candidate) => candidate.id === "check"); + if (action === undefined) throw new Error("Expected update check action"); + const checkForPiWebUpdates = vi.fn(() => Promise.resolve()); + const context = runtimeContext({ checkForPiWebUpdates }); + + expect(action.enabled?.(context)).toBe(true); + await action.run(context); + + expect(checkForPiWebUpdates).toHaveBeenCalledOnce(); + }); + + it("disables the action on older hosts without the update-check helper", () => { + const action = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }).contributions.actions?.find((candidate) => candidate.id === "check"); + if (action === undefined) throw new Error("Expected update check action"); + const context = runtimeContext(); + + expect(action.enabled?.(context)).toBe(false); + expect(action.disabledReason?.(context)).toContain("newer PI WEB gateway"); + }); +}); + +function runtimeContext(patch: Partial = {}): PluginRuntimeContext { + const noop = () => undefined; + return { + state: {}, + prompt: { insertText: noop, getText: () => "", getSelection: () => null }, + openActionPalette: noop, + focusPrompt: noop, + addProject: noop, + configureAuth: noop, + logoutAuth: noop, + openThemePicker: noop, + selectMainView: noop, + selectWorkspaceTool: noop, + openTerminal: noop, + refreshFiles: noop, + refreshGit: noop, + refreshAppData: noop, + reloadPage: noop, + startSession: noop, + archiveSession: noop, + stopActiveWork: noop, + ...patch, + }; +} diff --git a/pi-web-plugins/updates/pi-web-plugin.ts b/pi-web-plugins/updates/pi-web-plugin.ts index e2aa7f4..1078660 100644 --- a/pi-web-plugins/updates/pi-web-plugin.ts +++ b/pi-web-plugins/updates/pi-web-plugin.ts @@ -143,6 +143,7 @@ function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTermi
Generated ${status.generatedAt} ${status.release.latestVersion === undefined ? null : html`Latest npm release ${status.release.latestVersion}`} + ${status.release.checkedAt === undefined || status.release.skipped === true ? null : html`Release checked ${status.release.checkedAt}`} ${status.release.skipped === true ? html`Remote version check skipped.` : null} ${status.release.error === undefined ? null : html`Remote version check failed: ${status.release.error}`}
@@ -155,6 +156,17 @@ const plugin: PiWebPlugin = { name: "Updates", activate: ({ html, svg }) => ({ contributions: { + actions: [ + { + id: "check", + title: "Check for PI WEB Updates", + description: "Bypass cached release data and check the selected machine now", + group: "Updates", + enabled: (context) => context.checkForPiWebUpdates !== undefined, + disabledReason: () => "Update checks require a newer PI WEB gateway", + run: (context) => context.checkForPiWebUpdates?.(), + }, + ], workspacePanels: [ { id: "workspace.updates", diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 8f679d4..c3b5bf3 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -13,6 +13,20 @@ const workspace: Workspace = { isGitWorktree: true, }; +function piWebStatusResponse() { + return { + packageName: "@jmfederico/pi-web", + generatedAt: "now", + components: { + web: { component: "web", label: "PI WEB", available: true, stale: false }, + sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false }, + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, + commands: {}, + messages: [], + }; +} + const commandRun: TerminalCommandRun = { id: "run1", origin: "core", @@ -32,17 +46,7 @@ afterEach(() => { describe("machine-scoped runtime API", () => { it("reads machine PI WEB status through the gateway route", async () => { - const fetchMock = stubJsonFetch({ - packageName: "@jmfederico/pi-web", - generatedAt: "now", - components: { - web: { component: "web", label: "PI WEB", available: true, stale: false }, - sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false }, - }, - release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, - commands: {}, - messages: [], - }); + const fetchMock = stubJsonFetch(piWebStatusResponse()); await piWebApi.piWebStatus("remote a"); @@ -50,6 +54,26 @@ describe("machine-scoped runtime API", () => { expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status"); }); + it("requests an uncached update check through the local status route", async () => { + const fetchMock = stubJsonFetch(piWebStatusResponse()); + + await piWebApi.checkForUpdates(); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/pi-web/status?refresh=1"); + expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store"); + }); + + it("requests an uncached update check through the selected machine route", async () => { + const fetchMock = stubJsonFetch(piWebStatusResponse()); + + await piWebApi.checkForUpdates("remote a"); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status?refresh=1"); + expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store"); + }); + it("reads machine runtime through the gateway route", async () => { const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 61a6567..c0cfefa 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -99,8 +99,13 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef return cwd === undefined || cwd === "" ? { id } : { id, cwd }; } +function piWebStatusUrl(machineId: string): string { + return machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`; +} + export const piWebApi = { - piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse), + piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse), + checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }), piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse), }; diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index ad1fd65..a851f59 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -28,6 +28,7 @@ describe("federated route contract", () => { await Promise.all([ ignoreParseFailure(piWebApi.piWebStatus(machineId)), + ignoreParseFailure(piWebApi.checkForUpdates(machineId)), ignoreParseFailure(configApi.config(machineId)), ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)), ignoreParseFailure(pluginsApi.plugins(machineId)), diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 6e8a8d5..fee70a6 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { configApi, effectiveWorkspaceUploadFolder, piWebApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; +import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { isSessionActive } from "../../../shared/activity"; @@ -11,6 +11,7 @@ import { FileExplorerController } from "../controllers/fileExplorerController"; import { GitController } from "../controllers/gitController"; import { MachineController } from "../controllers/machineController"; import { ProjectController } from "../controllers/projectController"; +import { PiWebStatusController } from "../controllers/piWebStatusController"; import { SessionController } from "../controllers/sessionController"; import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController"; import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory"; @@ -132,6 +133,11 @@ export class PiWebApp extends LitElement { () => { this.updateUrl(); }, this.projects, ); + private readonly piWebStatusController = new PiWebStatusController( + () => this.state, + (patch) => { this.setState(patch); }, + { onRefreshError: (machineId, error) => { console.warn(`Failed to refresh PI WEB status for ${machineId}`, error); } }, + ); private readonly files = new FileExplorerController( () => this.state, (patch) => { this.setState(patch); }, @@ -298,7 +304,7 @@ export class PiWebApp extends LitElement { this.clearScheduledPiWebStatusRefresh(); this.piWebStatusDeferredTimer = window.setTimeout(() => { this.piWebStatusDeferredTimer = undefined; - void this.refreshPiWebStatus(); + void this.piWebStatusController.refresh(); }, delayMs); } @@ -308,17 +314,6 @@ export class PiWebApp extends LitElement { this.piWebStatusDeferredTimer = undefined; } - private async refreshPiWebStatus(): Promise { - const machineId = selectedMachineId(this.state); - try { - const piWebStatus = await piWebApi.piWebStatus(machineId); - if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus }); - } catch (error) { - if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined }); - console.warn(`Failed to refresh PI WEB status for ${machineId}`, error); - } - } - private async refreshWorkspaceActivity(machineId = selectedMachineId(this.state)): Promise { try { await this.activity.refresh(machineId); @@ -1573,6 +1568,7 @@ export class PiWebApp extends LitElement { refreshFiles: () => this.files.refreshFiles(), refreshGit: () => this.git.refreshGit(), refreshAppData: () => this.refreshAppData(), + checkForPiWebUpdates: () => this.piWebStatusController.checkForUpdates(), reloadPage: () => { this.hardReloadApp(); }, deleteWorkspace: (workspace) => this.deleteWorkspace(workspace), startSession: () => this.withChatScrollTransition(() => this.startSessionAndOpenChat()), diff --git a/src/client/src/controllers/piWebStatusController.test.ts b/src/client/src/controllers/piWebStatusController.test.ts new file mode 100644 index 0000000..1020fc7 --- /dev/null +++ b/src/client/src/controllers/piWebStatusController.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Machine, PiWebReleaseStatus, PiWebStatusResponse } from "../api"; +import { initialAppState, type AppState } from "../appState"; +import { PiWebStatusController, type PiWebStatusControllerDependencies } from "./piWebStatusController"; + +type StatusApi = NonNullable; + +describe("PiWebStatusController", () => { + it("targets the selected machine and applies refreshed status", async () => { + const harness = createHarness("remote-a"); + harness.piWebStatus.mockResolvedValue(status("remote")); + + await harness.controller.refresh(); + + expect(harness.piWebStatus).toHaveBeenCalledWith("remote-a"); + expect(harness.state().piWebStatus?.generatedAt).toBe("remote"); + }); + + it("does not let an older periodic response overwrite a forced response", async () => { + const harness = createHarness(); + const regular = createDeferred(); + const forced = createDeferred(); + harness.piWebStatus.mockReturnValue(regular.promise); + harness.checkForUpdates.mockReturnValue(forced.promise); + + const regularRequest = harness.controller.refresh(); + const forcedRequest = harness.controller.checkForUpdates(); + forced.resolve(status("forced")); + await forcedRequest; + regular.resolve(status("regular")); + await regularRequest; + + expect(harness.state().piWebStatus?.generatedAt).toBe("forced"); + }); + + it("deduplicates forced checks and suppresses periodic refresh while one is pending", async () => { + const harness = createHarness(); + const forced = createDeferred(); + harness.checkForUpdates.mockReturnValue(forced.promise); + + const first = harness.controller.checkForUpdates(); + const second = harness.controller.checkForUpdates(); + await harness.controller.refresh(); + + expect(second).toBe(first); + expect(harness.checkForUpdates).toHaveBeenCalledOnce(); + expect(harness.piWebStatus).not.toHaveBeenCalled(); + + forced.resolve(status("forced")); + await first; + }); + + it("does not apply a response or error after the selected machine changes", async () => { + const harness = createHarness("remote-a"); + const forced = createDeferred(); + harness.checkForUpdates.mockReturnValue(forced.promise); + + const request = harness.controller.checkForUpdates(); + harness.selectMachine("remote-b"); + forced.resolve(status("remote-a", { error: "registry unavailable" })); + await expect(request).resolves.toBeUndefined(); + + expect(harness.state().piWebStatus).toBeUndefined(); + }); + + it.each([ + [{ error: "registry unavailable" }, "PI WEB update check failed: registry unavailable"], + [{ skipped: true }, "PI WEB update check was skipped"], + ] as const)("applies status and rejects an unsuccessful manual check", async (release, message) => { + const harness = createHarness(); + harness.checkForUpdates.mockResolvedValue(status("checked", release)); + + await expect(harness.controller.checkForUpdates()).rejects.toThrow(message); + + expect(harness.state().piWebStatus?.generatedAt).toBe("checked"); + }); + + it("clears current status and reports periodic refresh failures", async () => { + const harness = createHarness(); + const error = new Error("offline"); + harness.setStatus(status("old")); + harness.piWebStatus.mockRejectedValue(error); + + await harness.controller.refresh(); + + expect(harness.state().piWebStatus).toBeUndefined(); + expect(harness.onRefreshError).toHaveBeenCalledWith("local", error); + }); +}); + +function createHarness(machineId = "local") { + let state: AppState = { ...initialAppState(), selectedMachine: machine(machineId) }; + const piWebStatus = vi.fn(); + const checkForUpdates = vi.fn(); + const onRefreshError = vi.fn<(machineId: string, error: unknown) => void>(); + const controller = new PiWebStatusController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + { api: { piWebStatus, checkForUpdates }, onRefreshError }, + ); + return { + controller, + piWebStatus, + checkForUpdates, + onRefreshError, + state: () => state, + setStatus: (piWebStatusValue: PiWebStatusResponse) => { state = { ...state, piWebStatus: piWebStatusValue }; }, + selectMachine: (id: string) => { state = { ...state, selectedMachine: machine(id) }; }, + }; +} + +function machine(id: string): Machine { + return { + id, + name: id, + kind: id === "local" ? "local" : "remote", + ...(id === "local" ? {} : { baseUrl: `https://${id}.example.test` }), + createdAt: "now", + updatedAt: "now", + }; +} + +function status(generatedAt: string, release: Partial = {}): PiWebStatusResponse { + return { + packageName: "@jmfederico/pi-web", + generatedAt, + components: { + web: { component: "web", label: "Web/UI", stale: false, available: true }, + sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true }, + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false, ...release }, + commands: {}, + messages: [], + }; +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} diff --git a/src/client/src/controllers/piWebStatusController.ts b/src/client/src/controllers/piWebStatusController.ts new file mode 100644 index 0000000..3254aab --- /dev/null +++ b/src/client/src/controllers/piWebStatusController.ts @@ -0,0 +1,68 @@ +import { piWebApi, type PiWebStatusResponse } from "../api"; +import { selectedMachineId, type GetState, type SetState } from "./types"; + +export interface PiWebStatusControllerDependencies { + api?: Pick; + onRefreshError?: (machineId: string, error: unknown) => void; +} + +export class PiWebStatusController { + private readonly api: Pick; + private readonly onRefreshError: (machineId: string, error: unknown) => void; + private requestSequence = 0; + private pendingUpdateCheck: { machineId: string; requestSequence: number; promise: Promise } | undefined; + + constructor( + private readonly getState: GetState, + private readonly setState: SetState, + dependencies: PiWebStatusControllerDependencies = {}, + ) { + this.api = dependencies.api ?? piWebApi; + this.onRefreshError = dependencies.onRefreshError ?? (() => undefined); + } + + async refresh(): Promise { + const machineId = selectedMachineId(this.getState()); + if (this.pendingUpdateCheck?.machineId === machineId) return; + const requestSequence = ++this.requestSequence; + try { + const piWebStatus = await this.api.piWebStatus(machineId); + if (this.isCurrent(machineId, requestSequence)) this.setState({ piWebStatus }); + } catch (error) { + if (!this.isCurrent(machineId, requestSequence)) return; + this.setState({ piWebStatus: undefined }); + this.onRefreshError(machineId, error); + } + } + + checkForUpdates(): Promise { + const machineId = selectedMachineId(this.getState()); + const existing = this.pendingUpdateCheck; + if (existing?.machineId === machineId) return existing.promise; + + const requestSequence = ++this.requestSequence; + const promise = this.api.checkForUpdates(machineId) + .then((piWebStatus) => { + if (!this.isCurrent(machineId, requestSequence)) return; + this.setState({ piWebStatus }); + throwForUnsuccessfulReleaseCheck(piWebStatus); + }) + .catch((error: unknown) => { + if (this.isCurrent(machineId, requestSequence)) throw error; + }) + .finally(() => { + if (this.pendingUpdateCheck?.requestSequence === requestSequence) this.pendingUpdateCheck = undefined; + }); + this.pendingUpdateCheck = { machineId, requestSequence, promise }; + return promise; + } + + private isCurrent(machineId: string, requestSequence: number): boolean { + return selectedMachineId(this.getState()) === machineId && requestSequence === this.requestSequence; + } +} + +function throwForUnsuccessfulReleaseCheck(status: PiWebStatusResponse): void { + if (status.release.error !== undefined) throw new Error(`PI WEB update check failed: ${status.release.error}`); + if (status.release.skipped === true) throw new Error("PI WEB update check was skipped because remote version checks are disabled by offline/version-check settings"); +} diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index fd5cb8e..a0cc1ee 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -112,6 +112,7 @@ export interface PluginRuntimeContext { refreshFiles: () => void | Promise; refreshGit: () => void | Promise; refreshAppData: () => void | Promise; + checkForPiWebUpdates?: () => void | Promise; reloadPage: () => void; deleteWorkspace: (workspace?: Workspace) => void | Promise; startSession: () => void | Promise; diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 13fcab4..6b43850 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -99,6 +99,8 @@ export interface PluginRuntimeContext { refreshFiles: () => void | Promise; refreshGit: () => void | Promise; refreshAppData: () => void | Promise; + /** Force a fresh PI WEB release check on the selected machine. Optional for compatibility with older hosts. */ + checkForPiWebUpdates?: () => void | Promise; reloadPage: () => void; startSession: () => void | Promise; archiveSession: () => void | Promise; diff --git a/src/server/app.piWebStatus.test.ts b/src/server/app.piWebStatus.test.ts new file mode 100644 index 0000000..20442ae --- /dev/null +++ b/src/server/app.piWebStatus.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PiWebStatusResponse } from "../shared/apiTypes.js"; +import { buildApp } from "./app.js"; + +describe("PI WEB status routes", () => { + it("forces a fresh status load when refresh is requested", async () => { + const get = vi.fn(() => Promise.resolve(status("cached"))); + const refresh = vi.fn(() => Promise.resolve(status("forced"))); + const app = await buildApp({ piWebStatusCache: { get, refresh }, clientDist: false, logger: false }); + + try { + const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" }); + const forcedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" }); + + expect(cachedResponse.json().generatedAt).toBe("cached"); + expect(forcedResponse.json().generatedAt).toBe("forced"); + expect(get).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledWith({ force: true }); + } finally { + await app.close(); + } + }); +}); + +function status(generatedAt: string): PiWebStatusResponse { + return { + packageName: "@jmfederico/pi-web", + generatedAt, + components: { + web: { component: "web", label: "Web/UI", stale: false, available: true }, + sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true }, + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, + commands: {}, + messages: [], + }; +} diff --git a/src/server/app.remoteProxy.test.ts b/src/server/app.remoteProxy.test.ts index 22ef140..e946c24 100644 --- a/src/server/app.remoteProxy.test.ts +++ b/src/server/app.remoteProxy.test.ts @@ -25,6 +25,23 @@ describe("buildApp remote machine proxy routes", () => { expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined); }); + it("preserves the force-refresh query when proxying update checks", async () => { + const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify({ ok: true })]), + })); + appTestContext.remoteClient = fakeRemoteClient({ request }); + + const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web/status?refresh=1` }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + expect(request).toHaveBeenCalledWith("GET", "/api/pi-web/status?refresh=1", undefined); + }); + it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => { const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); diff --git a/src/server/app.ts b/src/server/app.ts index 8d28f45..38d32b7 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -23,7 +23,7 @@ import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachin import { PiWebPluginService } from "./piWebPluginService.js"; import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js"; import { registerPiPackageRoutes } from "./piPackageRoutes.js"; -import { createPiWebStatusCache } from "./piWebStatusCache.js"; +import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; @@ -38,6 +38,7 @@ export interface AppDependencies { sessionDaemon?: SessionProxyDaemon; piWebPlugins?: Pick; piPackages?: PiPackageService; + piWebStatusCache?: PiWebStatusCache; config?: PiWebConfigService; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; @@ -136,9 +137,10 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus(sessionDaemon), { - onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); }, - }); + const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache( + ({ force }) => getPiWebStatus(sessionDaemon, { forceReleaseCheck: force }), + { onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } }, + ); const machines = deps.machines ?? new MachineService(undefined, { localRuntime: () => getPiWebRuntime(sessionDaemon), }); @@ -153,7 +155,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise piWebStatusCache.get()); + app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1" + ? piWebStatusCache.refresh({ force: true }) + : piWebStatusCache.get()); app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon)); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); app.get("/api/plugins", async () => piWebPlugins.plugins()); diff --git a/src/server/piWebReleaseLookupCache.test.ts b/src/server/piWebReleaseLookupCache.test.ts new file mode 100644 index 0000000..5607ddb --- /dev/null +++ b/src/server/piWebReleaseLookupCache.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPiWebReleaseLookupCache } from "./piWebReleaseLookupCache.js"; + +describe("createPiWebReleaseLookupCache", () => { + it("serves a fresh cached release lookup", async () => { + let now = 1_000; + const load = vi.fn(() => Promise.resolve("1.0.0")); + const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now }); + + await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 }); + now = 1_050; + await expect(cache.get("0.9.1")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 }); + + expect(load).toHaveBeenCalledOnce(); + expect(load).toHaveBeenCalledWith("0.9.0"); + }); + + it("bypasses a fresh lookup when forced", async () => { + let now = 1_000; + const load = vi.fn() + .mockResolvedValueOnce("1.0.0") + .mockResolvedValueOnce("1.1.0"); + const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now }); + + await cache.get("0.9.0"); + now = 1_050; + + await expect(cache.get("0.9.0", { force: true })).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 }); + await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it.each(["forced-first", "regular-first"] as const)("does not let an older regular lookup replace a forced result when %s completes", async (completionOrder) => { + const regular = createDeferred(); + const forced = createDeferred(); + const load = vi.fn() + .mockImplementationOnce(() => regular.promise) + .mockImplementationOnce(() => forced.promise); + const cache = createPiWebReleaseLookupCache(load); + + const regularLookup = cache.get("0.9.0"); + const forcedLookup = cache.get("0.9.0", { force: true }); + if (completionOrder === "forced-first") { + forced.resolve("2.0.0"); + await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" }); + regular.resolve("1.0.0"); + await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" }); + } else { + regular.resolve("1.0.0"); + await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" }); + forced.resolve("2.0.0"); + await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" }); + } + + await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "2.0.0" }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("makes regular callers join a pending forced lookup", async () => { + const forced = createDeferred(); + const load = vi.fn(() => forced.promise); + const cache = createPiWebReleaseLookupCache(load); + + const forcedLookup = cache.get("0.9.0", { force: true }); + const regularLookup = cache.get("0.9.0"); + + expect(regularLookup).toBe(forcedLookup); + forced.resolve("2.0.0"); + await expect(regularLookup).resolves.toMatchObject({ latestVersion: "2.0.0" }); + expect(load).toHaveBeenCalledOnce(); + }); +}); + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} diff --git a/src/server/piWebReleaseLookupCache.ts b/src/server/piWebReleaseLookupCache.ts new file mode 100644 index 0000000..35bd2b3 --- /dev/null +++ b/src/server/piWebReleaseLookupCache.ts @@ -0,0 +1,57 @@ +const DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS = 6 * 60 * 60 * 1000; + +export interface PiWebReleaseLookup { + checkedAtMs: number; + latestVersion?: string; + error?: string; +} + +export interface PiWebReleaseLookupCacheOptions { + ttlMs?: number; + now?: () => number; +} + +export interface PiWebReleaseLookupOptions { + force?: boolean; +} + +export interface PiWebReleaseLookupCache { + get(currentVersion: string, options?: PiWebReleaseLookupOptions): Promise; +} + +export function createPiWebReleaseLookupCache( + load: (currentVersion: string) => Promise, + options: PiWebReleaseLookupCacheOptions = {}, +): PiWebReleaseLookupCache { + const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS; + const now = options.now ?? Date.now; + let cached: PiWebReleaseLookup | undefined; + let pending: { promise: Promise; force: boolean; sequence: number } | undefined; + let loadSequence = 0; + + return { + get(currentVersion: string, lookupOptions: PiWebReleaseLookupOptions = {}): Promise { + const force = lookupOptions.force === true; + if (pending?.force === true) return pending.promise; + + const checkedAtMs = now(); + if (!force && cached !== undefined && checkedAtMs - cached.checkedAtMs < ttlMs) return Promise.resolve(cached); + if (!force && pending !== undefined) return pending.promise; + + const sequence = ++loadSequence; + const promise = Promise.resolve() + .then(() => load(currentVersion)) + .then((latestVersion): PiWebReleaseLookup => ({ checkedAtMs, latestVersion })) + .catch((error: unknown): PiWebReleaseLookup => ({ checkedAtMs, error: error instanceof Error ? error.message : String(error) })) + .then((lookup) => { + if (sequence === loadSequence) cached = lookup; + return lookup; + }) + .finally(() => { + if (pending?.sequence === sequence) pending = undefined; + }); + pending = { promise, force, sequence }; + return promise; + }, + }; +} diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 15cd1a5..77e0ab7 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -69,6 +69,33 @@ describe("PI WEB status", () => { expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings])); }); + it("bypasses cached npm release data for a forced check", async () => { + Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK"); + process.env["PI_WEB_DOCKER_RUNTIME"] = "1"; + process.env["PI_WEB_DOCKER_MODE"] = "runtime"; + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(npmVersionResponse("1.202607.1")) + .mockResolvedValueOnce(npmVersionResponse("1.202607.2")); + const daemon = daemonWithComponent({ + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202607.0", + installedVersion: "1.202607.0", + stale: false, + available: true, + installation: { kind: "docker", dockerMode: "runtime" }, + }); + + const first = await getPiWebStatus(daemon, { forceReleaseCheck: true }); + const cached = await getPiWebStatus(daemon); + const forced = await getPiWebStatus(daemon, { forceReleaseCheck: true }); + + expect(first.release.latestVersion).toBe("1.202607.1"); + expect(cached.release.latestVersion).toBe("1.202607.1"); + expect(forced.release.latestVersion).toBe("1.202607.2"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("reports stale session daemon versions as messages", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; disableDockerRuntimeEnv(); @@ -82,7 +109,7 @@ describe("PI WEB status", () => { installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, }); - const status = await getPiWebStatus(daemon); + const status = await getPiWebStatus(daemon, { forceReleaseCheck: true }); expect(status.release.skipped).toBe(true); expect(status.components.sessiond.stale).toBe(true); @@ -192,6 +219,10 @@ describe("PI WEB status", () => { }); }); +function npmVersionResponse(version: string): Response { + return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } }); +} + function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient { const daemon = new SessionDaemonClient(); vi.spyOn(daemon, "request").mockResolvedValue({ diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index 7385783..b616887 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -11,11 +11,11 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/ import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; +import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`; const DEFAULT_VERSION = "0.0.0-dev"; -const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000; const VERSION_CHECK_TIMEOUT_MS = 5000; type ServiceId = "sessiond" | "web" | "uiDev"; @@ -74,8 +74,11 @@ interface PiWebStatusDaemon { request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; } -let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined; +export interface PiWebStatusOptions { + forceReleaseCheck?: boolean; +} +const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion); const runtimePackageInfo = readPackageInfoSync(); export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent { @@ -129,10 +132,10 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess }; } -export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise { +export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise { const versionStatus = await getPiWebVersionStatus(daemon); const { web, sessiond } = versionStatus.components; - const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION); + const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true); const components = { web, sessiond }; const commands = await commandsFor(components); const messages = buildMessages(components, release, commands); @@ -375,25 +378,16 @@ function unavailableSessiond(error: string): PiWebComponentStatus { }; } -async function getLatestReleaseStatus(currentVersion: string): Promise { +async function getLatestReleaseStatus(currentVersion: string, force: boolean): Promise { const checkedAtMs = Date.now(); if (skipVersionCheck()) { return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true }; } - if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) { - return releaseStatusFromCache(latestReleaseCache, currentVersion); - } - - try { - latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) }; - } catch (error) { - latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) }; - } - return releaseStatusFromCache(latestReleaseCache, currentVersion); + return releaseStatusFromCache(await latestReleaseLookupCache.get(currentVersion, { force }), currentVersion); } -function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus { +function releaseStatusFromCache(cache: PiWebReleaseLookup, currentVersion: string): PiWebReleaseStatus { return { packageName: PI_WEB_PACKAGE_NAME, ...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }), diff --git a/src/server/piWebStatusCache.test.ts b/src/server/piWebStatusCache.test.ts index 0d94142..25ccee4 100644 --- a/src/server/piWebStatusCache.test.ts +++ b/src/server/piWebStatusCache.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { PiWebStatusResponse } from "../shared/apiTypes.js"; -import { createPiWebStatusCache } from "./piWebStatusCache.js"; +import { createPiWebStatusCache, type PiWebStatusCacheLoadOptions } from "./piWebStatusCache.js"; describe("createPiWebStatusCache", () => { it("serves cached status while it is fresh", async () => { @@ -46,6 +46,45 @@ describe("createPiWebStatusCache", () => { expect(load).toHaveBeenCalledTimes(2); }); + it.each(["forced-first", "regular-first"] as const)("does not let an older refresh replace a forced result when %s completes", async (completionOrder) => { + const regular = createDeferred(); + const forced = createDeferred(); + const load = vi.fn(({ force }: PiWebStatusCacheLoadOptions) => force ? forced.promise : regular.promise); + const cache = createPiWebStatusCache(load); + + const regularRefresh = cache.refresh(); + const forcedRefresh = cache.refresh({ force: true }); + if (completionOrder === "forced-first") { + forced.resolve(status("forced")); + await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" }); + regular.resolve(status("regular")); + await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" }); + } else { + regular.resolve(status("regular")); + await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" }); + forced.resolve(status("forced")); + await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" }); + } + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "forced" }); + expect(load).toHaveBeenNthCalledWith(1, { force: false }); + expect(load).toHaveBeenNthCalledWith(2, { force: true }); + }); + + it("makes regular refreshes join a pending forced refresh", async () => { + const deferred = createDeferred(); + const load = vi.fn(() => deferred.promise); + const cache = createPiWebStatusCache(load); + + const forced = cache.refresh({ force: true }); + const regular = cache.refresh(); + + expect(regular).toBe(forced); + deferred.resolve(status("forced")); + await forced; + expect(load).toHaveBeenCalledOnce(); + }); + it("retains stale status and reports background refresh errors", async () => { let now = 1_000; const refreshError = new Error("refresh failed"); diff --git a/src/server/piWebStatusCache.ts b/src/server/piWebStatusCache.ts index ec8813b..d8b8860 100644 --- a/src/server/piWebStatusCache.ts +++ b/src/server/piWebStatusCache.ts @@ -8,28 +8,42 @@ export interface PiWebStatusCacheOptions { onError?: (error: unknown) => void; } -export interface PiWebStatusCache { - get(): Promise; - refresh(): Promise; +export interface PiWebStatusCacheLoadOptions { + force: boolean; } -export function createPiWebStatusCache(load: () => Promise, options: PiWebStatusCacheOptions = {}): PiWebStatusCache { +export interface PiWebStatusCacheRefreshOptions { + force?: boolean; +} + +export interface PiWebStatusCache { + get(): Promise; + refresh(options?: PiWebStatusCacheRefreshOptions): Promise; +} + +export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise, options: PiWebStatusCacheOptions = {}): PiWebStatusCache { const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS; const now = options.now ?? Date.now; let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined; - let pending: Promise | undefined; + let pending: { promise: Promise; force: boolean; sequence: number } | undefined; + let loadSequence = 0; - const refresh = (): Promise => { - pending ??= Promise.resolve() - .then(load) + const refresh = (refreshOptions: PiWebStatusCacheRefreshOptions = {}): Promise => { + const force = refreshOptions.force === true; + if (pending !== undefined && (!force || pending.force)) return pending.promise; + + const sequence = ++loadSequence; + const promise = Promise.resolve() + .then(() => load({ force })) .then((status) => { - cached = { status, expiresAt: now() + ttlMs }; + if (sequence === loadSequence) cached = { status, expiresAt: now() + ttlMs }; return status; }) .finally(() => { - pending = undefined; + if (pending?.sequence === sequence) pending = undefined; }); - return pending; + pending = { promise, force, sequence }; + return promise; }; return { From 5ce793add9e170bfae4dddd537a6e05a42ed8b95 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 00:08:09 +0200 Subject: [PATCH 04/12] refactor(cli): define canonical native service plans --- src/nativeServices/servicePlan.test.ts | 356 ++++++++++++++++ src/nativeServices/servicePlan.ts | 540 +++++++++++++++++++++++++ 2 files changed, 896 insertions(+) create mode 100644 src/nativeServices/servicePlan.test.ts create mode 100644 src/nativeServices/servicePlan.ts diff --git a/src/nativeServices/servicePlan.test.ts b/src/nativeServices/servicePlan.test.ts new file mode 100644 index 0000000..f09f097 --- /dev/null +++ b/src/nativeServices/servicePlan.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createDevelopmentNativeServicePlan, + planValidationProbeRequests, + resolveProductionNativeServicePlan, + type NativeServiceAuthoritativeProbe, + type NativeServiceProbeRequest, + type NativeServiceProbeResult, + type ProductionNativeServicePlanInput, +} from "./servicePlan.js"; + +const backend = { kind: "systemd", label: "systemd user services" } as const; +const shell = { + name: "zsh", + executable: "/bin/zsh", + source: "detected", + detectedExecutable: "/bin/zsh", +} as const; + +function productionInput(): ProductionNativeServicePlanInput { + return { + backend, + shell, + environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" }, + executables: { + sessiond: { + configuredCommand: undefined, + namedCommand: "pi-web-sessiond", + bundledEntrypointPath: "/package/dist/server/sessiond.js", + }, + web: { + configuredCommand: undefined, + namedCommand: "pi-web-server", + bundledEntrypointPath: "/package/dist/server/index.js", + }, + }, + }; +} + +function completedProbe(status: "satisfied" | "unsatisfied", detail: string | null = null): NativeServiceAuthoritativeProbe { + return { + run: (request) => Promise.resolve({ + kind: "completed", + outcomes: request.prerequisites.map((prerequisite) => ({ prerequisiteId: prerequisite.id, status, detail })), + }), + }; +} + +describe("production native service planning", () => { + it("selects named commands from one authoritative backend probe and carries their exact requirements", async () => { + const requests: NativeServiceProbeRequest[] = []; + const probe: NativeServiceAuthoritativeProbe = { + run: (request) => { + requests.push(request); + return Promise.resolve({ + kind: "completed", + outcomes: request.prerequisites.map((prerequisite) => ({ + prerequisiteId: prerequisite.id, + status: "satisfied", + detail: "/usr/local/bin/example", + })), + }); + }, + }; + + const resolution = await resolveProductionNativeServicePlan(productionInput(), { + probe, + fileExists: () => false, + }); + + expect(requests).toEqual([ + { + purpose: "executable-selection", + backend, + shell, + environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" }, + workingDirectory: null, + prerequisites: [ + expect.objectContaining({ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" }), + expect.objectContaining({ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" }), + ], + }, + ]); + expect(resolution.ok).toBe(true); + if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures)); + + expect(resolution.plan).toMatchObject({ + mode: "production", + backend, + shell, + services: [ + { + id: "sessiond", + manager: { systemdName: "pi-web-sessiond.service", launchdLabel: "com.pi-web.sessiond" }, + shellCommand: "exec pi-web-sessiond", + strategy: { kind: "named-command", command: "pi-web-sessiond", selectedBy: "authoritative-backend-probe" }, + environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" }, + workingDirectory: null, + after: [], + wants: [], + prerequisites: [ + { id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" }, + { id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 }, + ], + }, + { + id: "web", + shellCommand: "exec pi-web-server", + strategy: { kind: "named-command", command: "pi-web-server" }, + after: ["sessiond"], + wants: ["sessiond"], + prerequisites: [ + { id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" }, + { id: "web.node", kind: "node-version", command: "node", minimumMajor: 22 }, + ], + }, + ], + }); + + expect(planValidationProbeRequests(resolution.plan)).toMatchObject([ + { + purpose: "plan-validation", + backend, + shell, + workingDirectory: null, + prerequisites: [{ id: "sessiond.command.pi-web-sessiond" }, { id: "sessiond.node" }], + }, + { + purpose: "plan-validation", + backend, + shell, + workingDirectory: null, + prerequisites: [{ id: "web.command.pi-web-server" }, { id: "web.node" }], + }, + ]); + }); + + it("preserves configured overrides verbatim and never probes or executes them", async () => { + const input = productionInput(); + input.executables.sessiond.configuredCommand = " /opt/pi web/run-sessiond --flag "; + input.executables.web.configuredCommand = "custom-web --serve"; + const run = vi.fn<(request: NativeServiceProbeRequest) => Promise>(); + const fileExists = vi.fn<(path: string) => boolean>(); + + const resolution = await resolveProductionNativeServicePlan(input, { probe: { run }, fileExists }); + + expect(run).not.toHaveBeenCalled(); + expect(fileExists).not.toHaveBeenCalled(); + expect(resolution.ok).toBe(true); + if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures)); + expect(resolution.plan.services).toMatchObject([ + { + id: "sessiond", + shellCommand: "exec /opt/pi web/run-sessiond --flag ", + strategy: { + kind: "configured-override", + command: " /opt/pi web/run-sessiond --flag ", + verification: "unverified", + }, + prerequisites: [], + }, + { + id: "web", + shellCommand: "exec custom-web --serve", + strategy: { kind: "configured-override", command: "custom-web --serve", verification: "unverified" }, + prerequisites: [], + }, + ]); + expect(planValidationProbeRequests(resolution.plan)).toEqual([]); + }); + + it("falls back per service to bundled entrypoints when named commands are unavailable", async () => { + const input = productionInput(); + input.executables.sessiond.bundledEntrypointPath = "/package with space/sessiond's entry.js"; + const fileExists = vi.fn<(path: string) => boolean>(() => true); + + const resolution = await resolveProductionNativeServicePlan(input, { + probe: completedProbe("unsatisfied", "command not found"), + fileExists, + }); + + expect(fileExists).toHaveBeenCalledTimes(2); + expect(resolution.ok).toBe(true); + if (!resolution.ok) throw new Error(JSON.stringify(resolution.failures)); + expect(resolution.plan.services[0]).toMatchObject({ + shellCommand: "exec node '/package with space/sessiond'\\''s entry.js'", + strategy: { + kind: "bundled-entrypoint", + command: "node", + namedCommand: "pi-web-sessiond", + namedCommandFailure: "command not found", + }, + prerequisites: [ + { id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 }, + { id: "sessiond.entrypoint", kind: "readable-file", path: "/package with space/sessiond's entry.js" }, + ], + }); + }); + + it("mixes configured, named, and bundled decisions without unrelated checks", async () => { + const input = productionInput(); + input.executables.sessiond.configuredCommand = "custom-sessiond"; + const requests: NativeServiceProbeRequest[] = []; + + const resolution = await resolveProductionNativeServicePlan(input, { + probe: { + run: (request) => { + requests.push(request); + return Promise.resolve({ + kind: "completed", + outcomes: [{ prerequisiteId: "web.command.pi-web-server", status: "unsatisfied", detail: null }], + }); + }, + }, + fileExists: () => true, + }); + + expect(requests[0]?.prerequisites).toMatchObject([{ id: "web.command.pi-web-server", command: "pi-web-server" }]); + expect(resolution.ok).toBe(true); + if (resolution.ok) { + expect(resolution.plan.services.map((service) => service.strategy.kind)).toEqual(["configured-override", "bundled-entrypoint"]); + } + }); + + it("returns structured failures when neither production executable strategy is viable", async () => { + const resolution = await resolveProductionNativeServicePlan(productionInput(), { + probe: completedProbe("unsatisfied", "not found in service PATH"), + fileExists: () => false, + }); + + expect(resolution).toEqual({ + ok: false, + failures: [ + { + kind: "executable-unavailable", + serviceId: "sessiond", + namedCommand: "pi-web-sessiond", + namedCommandFailure: "not found in service PATH", + bundledEntrypointPath: "/package/dist/server/sessiond.js", + }, + { + kind: "executable-unavailable", + serviceId: "web", + namedCommand: "pi-web-server", + namedCommandFailure: "not found in service PATH", + bundledEntrypointPath: "/package/dist/server/index.js", + }, + ], + }); + }); + + it("does not reinterpret probe infrastructure failures as missing commands", async () => { + const fileExists = vi.fn<(path: string) => boolean>(() => true); + const resolution = await resolveProductionNativeServicePlan(productionInput(), { + probe: { + run: () => Promise.resolve({ kind: "infrastructure-failure", message: "launchd probe cleanup failed" }), + }, + fileExists, + }); + + expect(fileExists).not.toHaveBeenCalled(); + expect(resolution).toEqual({ + ok: false, + failures: [{ + kind: "probe-infrastructure", + serviceIds: ["sessiond", "web"], + message: "launchd probe cleanup failed", + }], + }); + }); + + it("treats thrown and malformed probe results as infrastructure failures", async () => { + const thrown = await resolveProductionNativeServicePlan(productionInput(), { + probe: { run: () => Promise.reject(new Error("systemd-run failed")) }, + fileExists: () => true, + }); + expect(thrown).toMatchObject({ + ok: false, + failures: [{ kind: "probe-infrastructure", message: "systemd-run failed" }], + }); + + const malformed = await resolveProductionNativeServicePlan(productionInput(), { + probe: { + run: () => Promise.resolve({ + kind: "completed", + outcomes: [{ prerequisiteId: "sessiond.command.pi-web-sessiond", status: "satisfied", detail: null }], + }), + }, + fileExists: () => true, + }); + expect(malformed).toMatchObject({ + ok: false, + failures: [{ kind: "probe-infrastructure", message: "Authoritative probe returned no outcome for web.command.pi-web-server." }], + }); + }); +}); + +describe("development native service planning", () => { + it("plans only the exact checkout commands and prerequisites", () => { + const plan = createDevelopmentNativeServicePlan({ + backend: { kind: "launchd", label: "LaunchAgents" }, + shell: { name: "fish", executable: "/opt/homebrew/bin/fish", source: "detected", detectedExecutable: "/opt/homebrew/bin/fish" }, + environment: { PI_WEB_CONFIG: "/tmp/config.json" }, + workingDirectory: "/checkout with space", + packageJsonPath: "/checkout with space/package.json", + }); + + expect(plan).toMatchObject({ + mode: "development", + backend: { kind: "launchd" }, + shell: { name: "fish", executable: "/opt/homebrew/bin/fish" }, + services: [ + { + id: "sessiond", + shellCommand: "exec npm run start:sessiond", + strategy: { kind: "development-npm-script", script: "start:sessiond" }, + restart: "never", + environment: { PI_WEB_CONFIG: "/tmp/config.json" }, + workingDirectory: "/checkout with space", + prerequisites: [ + { id: "sessiond.node", kind: "node-version", minimumMajor: 22 }, + { id: "sessiond.command.npm", kind: "command-available", command: "npm" }, + { id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] }, + ], + }, + { + id: "uiDev", + shellCommand: "exec /usr/bin/env bash -c 'trap \"kill 0\" EXIT; npm run dev:web & npm run dev:client & wait'", + strategy: { kind: "development-npm-script-group", scripts: ["dev:web", "dev:client"], interpreter: "bash" }, + restart: "never", + workingDirectory: "/checkout with space", + after: ["sessiond"], + wants: ["sessiond"], + prerequisites: [ + { id: "uiDev.node", kind: "node-version", minimumMajor: 22 }, + { id: "uiDev.command.npm", kind: "command-available", command: "npm" }, + { id: "uiDev.command.bash", kind: "command-available", command: "bash" }, + { id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] }, + ], + }, + ], + }); + + const serviceCommandRequirements = plan.services.flatMap((service) => service.prerequisites) + .filter((prerequisite) => prerequisite.kind === "command-available") + .map((prerequisite) => prerequisite.command); + expect(serviceCommandRequirements).toEqual(["npm", "npm", "bash"]); + expect(serviceCommandRequirements).not.toContain("pi-web-server"); + expect(serviceCommandRequirements).not.toContain("pi-web-sessiond"); + + expect(planValidationProbeRequests(plan)).toMatchObject([ + { backend: { kind: "launchd" }, workingDirectory: "/checkout with space" }, + { backend: { kind: "launchd" }, workingDirectory: "/checkout with space" }, + ]); + }); +}); diff --git a/src/nativeServices/servicePlan.ts b/src/nativeServices/servicePlan.ts new file mode 100644 index 0000000..f0d3404 --- /dev/null +++ b/src/nativeServices/servicePlan.ts @@ -0,0 +1,540 @@ +export type NativeServiceBackendKind = "systemd" | "launchd"; +export type NativeServiceMode = "production" | "development"; +export type NativeServiceId = "sessiond" | "web" | "uiDev"; +export type ProductionNativeServiceId = Extract; +export type NativeServiceShellName = "bash" | "zsh" | "fish"; +export type NativeServiceRestartPolicy = "on-failure" | "never"; + +export interface NativeServiceBackend { + kind: NativeServiceBackendKind; + label: string; +} + +export interface NativeServiceShell { + name: NativeServiceShellName; + executable: string; + source: "detected" | "fallback"; + detectedExecutable: string | null; +} + +export interface NativeServiceManagerRef { + systemdName: string; + launchdLabel: string; + launchdPlistName: string; + logName: string; +} + +export type NativeServiceCommandStrategy = + | { + kind: "configured-override"; + command: string; + verification: "unverified"; + } + | { + kind: "named-command"; + command: string; + selectedBy: "authoritative-backend-probe"; + } + | { + kind: "bundled-entrypoint"; + command: "node"; + entrypointPath: string; + namedCommand: string; + namedCommandFailure: string | null; + } + | { + kind: "development-npm-script"; + script: string; + } + | { + kind: "development-npm-script-group"; + scripts: readonly string[]; + interpreter: "bash"; + }; + +export type NativeServicePrerequisite = + | { + id: string; + kind: "command-available"; + command: string; + description: string; + } + | { + id: string; + kind: "node-version"; + command: "node"; + minimumMajor: number; + description: string; + } + | { + id: string; + kind: "readable-file"; + path: string; + description: string; + } + | { + id: string; + kind: "package-scripts"; + packageJsonPath: string; + scripts: readonly string[]; + description: string; + }; + +export interface NativeServicePlanService { + id: NativeServiceId; + manager: NativeServiceManagerRef; + description: string; + shellCommand: string; + strategy: NativeServiceCommandStrategy; + restart: NativeServiceRestartPolicy; + environment: Readonly>; + workingDirectory: string | null; + after: readonly NativeServiceId[]; + wants: readonly NativeServiceId[]; + prerequisites: readonly NativeServicePrerequisite[]; +} + +export interface NativeServicePlan { + mode: NativeServiceMode; + backend: NativeServiceBackend; + shell: NativeServiceShell; + services: readonly NativeServicePlanService[]; +} + +export interface NativeServiceProbeRequest { + purpose: "executable-selection" | "plan-validation"; + backend: NativeServiceBackend; + shell: NativeServiceShell; + environment: Readonly>; + workingDirectory: string | null; + prerequisites: readonly NativeServicePrerequisite[]; +} + +export interface NativeServicePrerequisiteOutcome { + prerequisiteId: string; + status: "satisfied" | "unsatisfied"; + detail: string | null; +} + +export type NativeServiceProbeResult = + | { + kind: "completed"; + outcomes: readonly NativeServicePrerequisiteOutcome[]; + } + | { + kind: "infrastructure-failure"; + message: string; + }; + +/** + * Runs requirements in the real native service-manager context represented by + * the request. Implementations must not treat the caller shell or a simulated + * `env -i` environment as authoritative. Timeouts, manager failures, malformed + * output, and cleanup failures are infrastructure failures; a missing command + * is a completed probe with an unsatisfied outcome. + */ +export interface NativeServiceAuthoritativeProbe { + run(request: NativeServiceProbeRequest): Promise; +} + +export interface ProductionNativeServiceExecutableInput { + configuredCommand: string | undefined; + namedCommand: string; + bundledEntrypointPath: string; +} + +export interface ProductionNativeServicePlanInput { + backend: NativeServiceBackend; + shell: NativeServiceShell; + environment: Readonly>; + executables: Readonly>; +} + +export interface DevelopmentNativeServicePlanInput { + backend: NativeServiceBackend; + shell: NativeServiceShell; + environment: Readonly>; + workingDirectory: string; + packageJsonPath: string; +} + +export interface NativeServicePlanDependencies { + probe: NativeServiceAuthoritativeProbe; + fileExists(path: string): boolean; +} + +export type NativeServicePlanFailure = + | { + kind: "probe-infrastructure"; + serviceIds: readonly ProductionNativeServiceId[]; + message: string; + } + | { + kind: "entrypoint-inspection-failure"; + serviceId: ProductionNativeServiceId; + entrypointPath: string; + message: string; + } + | { + kind: "executable-unavailable"; + serviceId: ProductionNativeServiceId; + namedCommand: string; + namedCommandFailure: string | null; + bundledEntrypointPath: string; + }; + +export type NativeServicePlanResolution = + | { ok: true; plan: NativeServicePlan } + | { ok: false; failures: readonly NativeServicePlanFailure[] }; + +const nativeServiceRefs: Readonly> = { + sessiond: { + systemdName: "pi-web-sessiond.service", + launchdLabel: "com.pi-web.sessiond", + launchdPlistName: "com.pi-web.sessiond.plist", + logName: "sessiond.log", + }, + web: { + systemdName: "pi-web.service", + launchdLabel: "com.pi-web.web", + launchdPlistName: "com.pi-web.web.plist", + logName: "web.log", + }, + uiDev: { + systemdName: "pi-web-ui-dev.service", + launchdLabel: "com.pi-web.ui-dev", + launchdPlistName: "com.pi-web.ui-dev.plist", + logName: "ui-dev.log", + }, +}; + +const productionServiceIds = ["sessiond", "web"] as const satisfies readonly ProductionNativeServiceId[]; + +export async function resolveProductionNativeServicePlan( + input: ProductionNativeServicePlanInput, + dependencies: NativeServicePlanDependencies, +): Promise { + const configuredStrategies = new Map(); + const selectionRequirements: NativeServicePrerequisite[] = []; + const serviceIdsToProbe: ProductionNativeServiceId[] = []; + + for (const serviceId of productionServiceIds) { + const executable = input.executables[serviceId]; + if (hasConfiguredCommand(executable.configuredCommand)) { + configuredStrategies.set(serviceId, { + kind: "configured-override", + command: executable.configuredCommand, + verification: "unverified", + }); + continue; + } + + serviceIdsToProbe.push(serviceId); + selectionRequirements.push(commandRequirement(serviceId, executable.namedCommand)); + } + + let outcomes = new Map(); + if (selectionRequirements.length > 0) { + const probeResult = await runSelectionProbe(input, selectionRequirements, dependencies.probe); + if (probeResult.kind === "infrastructure-failure") { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, message: probeResult.message }], + }; + } + + const parsedOutcomes = probeOutcomes(selectionRequirements, probeResult.outcomes); + if (parsedOutcomes.kind === "infrastructure-failure") { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", serviceIds: serviceIdsToProbe, message: parsedOutcomes.message }], + }; + } + outcomes = parsedOutcomes.outcomes; + } + + const strategies = new Map(configuredStrategies); + const failures: NativeServicePlanFailure[] = []; + + for (const serviceId of serviceIdsToProbe) { + const executable = input.executables[serviceId]; + const outcome = outcomes.get(commandRequirementId(serviceId, executable.namedCommand)); + if (outcome?.status === "satisfied") { + strategies.set(serviceId, { + kind: "named-command", + command: executable.namedCommand, + selectedBy: "authoritative-backend-probe", + }); + continue; + } + + let entrypointExists: boolean; + try { + entrypointExists = dependencies.fileExists(executable.bundledEntrypointPath); + } catch (error: unknown) { + failures.push({ + kind: "entrypoint-inspection-failure", + serviceId, + entrypointPath: executable.bundledEntrypointPath, + message: errorMessage(error), + }); + continue; + } + + if (entrypointExists) { + strategies.set(serviceId, { + kind: "bundled-entrypoint", + command: "node", + entrypointPath: executable.bundledEntrypointPath, + namedCommand: executable.namedCommand, + namedCommandFailure: outcome?.detail ?? null, + }); + continue; + } + + failures.push({ + kind: "executable-unavailable", + serviceId, + namedCommand: executable.namedCommand, + namedCommandFailure: outcome?.detail ?? null, + bundledEntrypointPath: executable.bundledEntrypointPath, + }); + } + + if (failures.length > 0) return { ok: false, failures }; + + return { + ok: true, + plan: { + mode: "production", + backend: input.backend, + shell: input.shell, + services: productionServiceIds.map((serviceId) => productionService(input, serviceId, requiredStrategy(strategies, serviceId))), + }, + }; +} + +export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServicePlanInput): NativeServicePlan { + const environment = copyEnvironment(input.environment); + const sessiondScripts = ["start:sessiond"] as const; + const uiDevScripts = ["dev:web", "dev:client"] as const; + const uiDevCommand = 'trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait'; + + return { + mode: "development", + backend: input.backend, + shell: input.shell, + services: [ + { + id: "sessiond", + manager: nativeServiceRefs.sessiond, + description: "PI WEB session daemon (dev)", + shellCommand: "exec npm run start:sessiond", + strategy: { kind: "development-npm-script", script: "start:sessiond" }, + restart: "never", + environment, + workingDirectory: input.workingDirectory, + after: [], + wants: [], + prerequisites: [ + nodeRequirement("sessiond"), + commandRequirement("sessiond", "npm"), + packageScriptsRequirement("sessiond", input.packageJsonPath, sessiondScripts), + ], + }, + { + id: "uiDev", + manager: nativeServiceRefs.uiDev, + description: "PI WEB UI dev server", + shellCommand: `exec /usr/bin/env bash -c ${shellSingleQuote(input.shell.name, uiDevCommand)}`, + strategy: { kind: "development-npm-script-group", scripts: uiDevScripts, interpreter: "bash" }, + restart: "never", + environment, + workingDirectory: input.workingDirectory, + after: ["sessiond"], + wants: ["sessiond"], + prerequisites: [ + nodeRequirement("uiDev"), + commandRequirement("uiDev", "npm"), + commandRequirement("uiDev", "bash"), + packageScriptsRequirement("uiDev", input.packageJsonPath, uiDevScripts), + ], + }, + ], + }; +} + +export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] { + return plan.services.flatMap((service) => service.prerequisites.length === 0 ? [] : [{ + purpose: "plan-validation" as const, + backend: plan.backend, + shell: plan.shell, + environment: service.environment, + workingDirectory: service.workingDirectory, + prerequisites: service.prerequisites, + }]); +} + +function productionService( + input: ProductionNativeServicePlanInput, + serviceId: ProductionNativeServiceId, + strategy: NativeServiceCommandStrategy, +): NativeServicePlanService { + const isWeb = serviceId === "web"; + return { + id: serviceId, + manager: nativeServiceRefs[serviceId], + description: isWeb ? "PI WEB server" : "PI WEB session daemon", + shellCommand: `exec ${strategyCommand(input.shell, strategy)}`, + strategy, + restart: "on-failure", + environment: copyEnvironment(input.environment), + workingDirectory: null, + after: isWeb ? ["sessiond"] : [], + wants: isWeb ? ["sessiond"] : [], + prerequisites: strategyPrerequisites(serviceId, strategy), + }; +} + +function strategyCommand(shell: NativeServiceShell, strategy: NativeServiceCommandStrategy): string { + switch (strategy.kind) { + case "configured-override": + case "named-command": + return strategy.command; + case "bundled-entrypoint": + return `${strategy.command} ${shellSingleQuote(shell.name, strategy.entrypointPath)}`; + case "development-npm-script": + return `npm run ${strategy.script}`; + case "development-npm-script-group": + throw new Error("Development script groups define their complete service shell command"); + } +} + +function strategyPrerequisites(serviceId: ProductionNativeServiceId, strategy: NativeServiceCommandStrategy): readonly NativeServicePrerequisite[] { + switch (strategy.kind) { + case "configured-override": + return []; + case "named-command": + return [commandRequirement(serviceId, strategy.command), nodeRequirement(serviceId)]; + case "bundled-entrypoint": + return [nodeRequirement(serviceId), readableFileRequirement(serviceId, strategy.entrypointPath)]; + case "development-npm-script": + case "development-npm-script-group": + throw new Error(`Unexpected ${strategy.kind} strategy in a production plan`); + } +} + +async function runSelectionProbe( + input: ProductionNativeServicePlanInput, + prerequisites: readonly NativeServicePrerequisite[], + probe: NativeServiceAuthoritativeProbe, +): Promise { + try { + return await probe.run({ + purpose: "executable-selection", + backend: input.backend, + shell: input.shell, + environment: copyEnvironment(input.environment), + workingDirectory: null, + prerequisites, + }); + } catch (error: unknown) { + return { kind: "infrastructure-failure", message: errorMessage(error) }; + } +} + +function probeOutcomes( + prerequisites: readonly NativeServicePrerequisite[], + outcomes: readonly NativeServicePrerequisiteOutcome[], +): { kind: "completed"; outcomes: Map } | { kind: "infrastructure-failure"; message: string } { + const expectedIds = new Set(prerequisites.map((prerequisite) => prerequisite.id)); + const byId = new Map(); + + for (const outcome of outcomes) { + if (!expectedIds.has(outcome.prerequisiteId)) { + return { kind: "infrastructure-failure", message: `Authoritative probe returned unexpected outcome ${outcome.prerequisiteId}.` }; + } + if (byId.has(outcome.prerequisiteId)) { + return { kind: "infrastructure-failure", message: `Authoritative probe returned duplicate outcome ${outcome.prerequisiteId}.` }; + } + byId.set(outcome.prerequisiteId, outcome); + } + + const missing = prerequisites.find((prerequisite) => !byId.has(prerequisite.id)); + if (missing !== undefined) { + return { kind: "infrastructure-failure", message: `Authoritative probe returned no outcome for ${missing.id}.` }; + } + return { kind: "completed", outcomes: byId }; +} + +function requiredStrategy( + strategies: ReadonlyMap, + serviceId: ProductionNativeServiceId, +): NativeServiceCommandStrategy { + const strategy = strategies.get(serviceId); + if (strategy === undefined) throw new Error(`Missing executable strategy for ${serviceId}`); + return strategy; +} + +function hasConfiguredCommand(command: string | undefined): command is string { + return command !== undefined && command.trim() !== ""; +} + +function commandRequirementId(serviceId: NativeServiceId, command: string): string { + return `${serviceId}.command.${command}`; +} + +function commandRequirement(serviceId: NativeServiceId, command: string): NativeServicePrerequisite { + return { + id: commandRequirementId(serviceId, command), + kind: "command-available", + command, + description: `${command} is available to the service shell`, + }; +} + +function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite { + return { + id: `${serviceId}.node`, + kind: "node-version", + command: "node", + minimumMajor: 22, + description: "node >= 22 is available to the service shell", + }; +} + +function readableFileRequirement(serviceId: NativeServiceId, path: string): NativeServicePrerequisite { + return { + id: `${serviceId}.entrypoint`, + kind: "readable-file", + path, + description: `bundled entrypoint is readable: ${path}`, + }; +} + +function packageScriptsRequirement( + serviceId: NativeServiceId, + packageJsonPath: string, + scripts: readonly string[], +): NativeServicePrerequisite { + return { + id: `${serviceId}.package-scripts`, + kind: "package-scripts", + packageJsonPath, + scripts, + description: `package.json defines scripts: ${scripts.join(", ")}`, + }; +} + +function shellSingleQuote(shell: NativeServiceShellName, value: string): string { + if (shell === "fish") return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function copyEnvironment(environment: Readonly>): Readonly> { + return { ...environment }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} From 3ac6679952e8c854abe8a103279be90e5bc1f4b3 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 00:25:53 +0200 Subject: [PATCH 05/12] fix(cli): preflight native services in manager context --- src/cli.ts | 381 ++++++-------- src/nativeServices/serviceInstall.test.ts | 141 ++++++ src/nativeServices/serviceInstall.ts | 60 +++ src/nativeServices/servicePlan.test.ts | 36 +- src/nativeServices/servicePlan.ts | 123 ++++- src/nativeServices/serviceProbe.test.ts | 306 ++++++++++++ src/nativeServices/serviceProbe.ts | 519 ++++++++++++++++++++ src/nativeServices/serviceRendering.test.ts | 61 +++ src/nativeServices/serviceRendering.ts | 129 +++++ 9 files changed, 1481 insertions(+), 275 deletions(-) create mode 100644 src/nativeServices/serviceInstall.test.ts create mode 100644 src/nativeServices/serviceInstall.ts create mode 100644 src/nativeServices/serviceProbe.test.ts create mode 100644 src/nativeServices/serviceProbe.ts create mode 100644 src/nativeServices/serviceRendering.test.ts create mode 100644 src/nativeServices/serviceRendering.ts diff --git a/src/cli.ts b/src/cli.ts index 00f1409..6783394 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,22 @@ import { fileURLToPath } from "node:url"; import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js"; import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js"; import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; +import { + installNativeServiceCandidate, + type NativeServiceInstallCandidate, + type NativeServiceInstallFailure, +} from "./nativeServices/serviceInstall.js"; +import { + nativeServiceManagerRefs, + productionNativeServiceIds, + type NativeServiceBackend, + type NativeServiceId, + type NativeServiceManagerRef, + type NativeServicePlan, + type NativeServiceShell, +} from "./nativeServices/servicePlan.js"; +import { createNativeServiceAuthoritativeProbe } from "./nativeServices/serviceProbe.js"; +import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; @@ -15,16 +31,10 @@ const systemdServiceDir = join(homedir(), ".config", "systemd", "user"); const launchdServiceDir = join(homedir(), "Library", "LaunchAgents"); const logDir = join(defaultPiWebDataDir(), "logs"); -const sessiondServiceName = "pi-web-sessiond.service"; -const webServiceName = "pi-web.service"; -const uiDevServiceName = "pi-web-ui-dev.service"; - type InstallMode = "production" | "dev"; -type ServiceBackendKind = "systemd" | "launchd"; -type ServiceId = "sessiond" | "web" | "uiDev"; +type ServiceId = NativeServiceId; +type ServiceBackend = NativeServiceBackend; type Check = [string, string[]]; -type SupportedShell = "bash" | "zsh" | "fish"; -type RestartPolicy = "on-failure" | "never"; interface InstallOptions { host: string; @@ -33,34 +43,8 @@ interface InstallOptions { config?: string; } -interface ServiceBackend { - kind: ServiceBackendKind; - label: string; -} - -interface ServiceRef { +interface ServiceRef extends NativeServiceManagerRef { id: ServiceId; - systemdName: string; - launchdLabel: string; - launchdPlistName: string; - logName: string; -} - -interface ServiceDefinition extends ServiceRef { - description: string; - shellCommand: string; - restart: RestartPolicy; - environment: Record; - after?: ServiceId[]; - wants?: ServiceId[]; - workingDirectory?: string; -} - -interface ServiceShell { - name: SupportedShell; - executable: string; - detected?: string; - fallback: boolean; } interface ServiceExecutable { @@ -85,30 +69,12 @@ interface ServiceRuntimeStatus { } const serviceRefs: Record = { - sessiond: { - id: "sessiond", - systemdName: sessiondServiceName, - launchdLabel: "com.pi-web.sessiond", - launchdPlistName: "com.pi-web.sessiond.plist", - logName: "sessiond.log", - }, - web: { - id: "web", - systemdName: webServiceName, - launchdLabel: "com.pi-web.web", - launchdPlistName: "com.pi-web.web.plist", - logName: "web.log", - }, - uiDev: { - id: "uiDev", - systemdName: uiDevServiceName, - launchdLabel: "com.pi-web.ui-dev", - launchdPlistName: "com.pi-web.ui-dev.plist", - logName: "ui-dev.log", - }, + sessiond: { id: "sessiond", ...nativeServiceManagerRefs.sessiond }, + web: { id: "web", ...nativeServiceManagerRefs.web }, + uiDev: { id: "uiDev", ...nativeServiceManagerRefs.uiDev }, }; -const productionServiceIds: ServiceId[] = ["sessiond", "web"]; +const productionServiceIds: ServiceId[] = [...productionNativeServiceIds]; const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"]; const stopServiceOrder: ServiceId[] = ["web", "uiDev", "sessiond"]; // Restart web/UI before sessiond: when `pi-web restart` runs in a pi-web @@ -236,23 +202,6 @@ function fishSingleQuote(value: string): string { return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; } -function systemdEscape(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); -} - -function systemdQuotedValue(value: string): string { - return `"${systemdEscape(value)}"`; -} - -function xmlEscape(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - function packageRootPath(): string { return dirname(dirname(fileURLToPath(import.meta.url))); } @@ -261,15 +210,25 @@ function packageEntrypointPath(name: "server" | "sessiond"): string { return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js"); } -function detectServiceShell(): ServiceShell { +function detectServiceShell(): NativeServiceShell { const userShell = userInfo().shell ?? undefined; const envShell = process.env["SHELL"]?.trim(); const detected = envShell === undefined || envShell === "" ? userShell : envShell; const name = basename(detected ?? "").replace(/^-/, ""); if (name === "bash" || name === "zsh" || name === "fish") { - return { name, executable: detected ?? name, detected: detected ?? name, fallback: false }; + return { + name, + executable: detected ?? name, + source: "detected", + detectedExecutable: detected ?? name, + }; } - return { name: "bash", executable: "bash", ...(detected === undefined ? {} : { detected }), fallback: true }; + return { + name: "bash", + executable: "bash", + source: "fallback", + detectedExecutable: detected ?? null, + }; } function serviceShellCommand(command: string, cwd?: string): string[] { @@ -277,18 +236,10 @@ function serviceShellCommand(command: string, cwd?: string): string[] { return ["/usr/bin/env", detectServiceShell().executable, "-lc", fullCommand]; } -function serviceShellExecPrefix(): string { - return `/usr/bin/env ${detectServiceShell().executable} -lc`; -} - function serviceShellQuote(value: string): string { return detectServiceShell().name === "fish" ? fishSingleQuote(value) : shellSingleQuote(value); } -function systemdServiceShellQuote(value: string): string { - return serviceShellQuote(value.replaceAll("%", "%%").replaceAll("$", "$$")); -} - function checkSucceeds(command: string[]): boolean { const [bin, ...args] = command; return bin !== undefined && capture(bin, args).status === 0; @@ -341,12 +292,12 @@ function resolveServiceExecutables(backend: ServiceBackend): ServiceExecutables function describeServiceShell(): string { const shell = detectServiceShell(); - if (shell.fallback) { - return shell.detected === undefined + if (shell.source === "fallback") { + return shell.detectedExecutable === null ? "could not detect a supported login shell; using bash" - : `detected ${shell.detected}; using bash because PI WEB currently supports bash, zsh, and fish`; + : `detected ${shell.detectedExecutable}; using bash because PI WEB currently supports bash, zsh, and fish`; } - return shell.detected === undefined ? shell.name : `${shell.name} (${shell.detected})`; + return shell.detectedExecutable === null ? shell.name : `${shell.name} (${shell.detectedExecutable})`; } function configEnvironment(options: InstallOptions, configPath: string): Record { @@ -385,28 +336,6 @@ function restartOrder(refs: ServiceRef[]): ServiceRef[] { return orderServiceRefs(refs, restartServiceOrder); } -function productionServiceDefinitions(options: InstallOptions, configPath: string, executables: ServiceExecutables): ServiceDefinition[] { - const environment = configEnvironment(options, configPath); - return [ - { - ...serviceRefs.sessiond, - description: "PI WEB session daemon", - shellCommand: `exec ${executables.sessiond.command}`, - restart: "on-failure", - environment, - }, - { - ...serviceRefs.web, - description: "PI WEB server", - shellCommand: `exec ${executables.web.command}`, - restart: "on-failure", - environment, - after: ["sessiond"], - wants: ["sessiond"], - }, - ]; -} - function devRootPath(): string { return resolve(process.cwd()); } @@ -421,104 +350,21 @@ function validateDevCheckout(root: string): void { if (!isRecord(parsed) || parsed["name"] !== PI_WEB_PACKAGE_NAME) { throw new Error(`Development mode must be installed from a PI WEB checkout. ${packageJsonPath} is not ${PI_WEB_PACKAGE_NAME}.`); } - - const scripts = parsed["scripts"]; - if (!isRecord(scripts)) throw new Error(`Development mode requires npm scripts in ${packageJsonPath}.`); - const requiredScripts = ["start:sessiond", "dev:web", "dev:client"]; - const missing = requiredScripts.filter((script) => typeof scripts[script] !== "string"); - if (missing.length > 0) throw new Error(`Development mode requires missing npm scripts: ${missing.join(", ")}.`); -} - -function devServiceDefinitions(options: InstallOptions, configPath: string, root: string): ServiceDefinition[] { - const environment = configEnvironment(options, configPath); - return [ - { - ...serviceRefs.sessiond, - description: "PI WEB session daemon (dev)", - shellCommand: "exec npm run start:sessiond", - restart: "never", - environment, - workingDirectory: root, - }, - { - ...serviceRefs.uiDev, - description: "PI WEB UI dev server", - shellCommand: `exec /usr/bin/env bash -c ${serviceShellQuote('trap "kill 0" EXIT; npm run dev:web & npm run dev:client & wait')}`, - restart: "never", - environment, - after: ["sessiond"], - wants: ["sessiond"], - workingDirectory: root, - }, - ]; -} - -function dependencyLine(name: "After" | "Wants", ids: ServiceId[] | undefined): string { - if (ids === undefined || ids.length === 0) return ""; - return `${name}=${ids.map((id) => serviceRefs[id].systemdName).join(" ")}\n`; -} - -function environmentLines(environment: Record): string { - return Object.entries(environment) - .map(([key, value]) => `Environment="${key}=${systemdEscape(value)}"\n`) - .join(""); -} - -function systemdUnit(service: ServiceDefinition): string { - const workingDirectory = service.workingDirectory === undefined ? "" : `WorkingDirectory=${systemdQuotedValue(service.workingDirectory)}\n`; - const restart = service.restart === "on-failure" ? "Restart=on-failure\nRestartSec=2\n" : "Restart=no\n"; - return `[Unit] -Description=${service.description} -${dependencyLine("After", service.after)}${dependencyLine("Wants", service.wants)} -[Service] -Type=simple -${workingDirectory}${environmentLines(service.environment)}ExecStart=${serviceShellExecPrefix()} ${systemdServiceShellQuote(service.shellCommand)} -${restart} -[Install] -WantedBy=default.target -`; -} - -function plistString(key: string, value: string, indent = " "): string { - return `${indent}${xmlEscape(key)}\n${indent}${xmlEscape(value)}\n`; -} - -function plistProgramArguments(service: ServiceDefinition): string { - const args = ["/usr/bin/env", detectServiceShell().executable, "-lc", service.shellCommand]; - return ` ProgramArguments\n \n${args.map((arg) => ` ${xmlEscape(arg)}`).join("\n")}\n \n`; -} - -function plistEnvironment(environment: Record): string { - const entries = Object.entries(environment); - if (entries.length === 0) return ""; - return ` EnvironmentVariables\n \n${entries.map(([key, value]) => plistString(key, value, " ")).join("")} \n`; } function launchdLogPath(ref: ServiceRef): string { return join(logDir, ref.logName); } -function launchdPlist(service: ServiceDefinition): string { - const workingDirectory = service.workingDirectory === undefined ? "" : plistString("WorkingDirectory", service.workingDirectory); - const keepAlive = service.restart === "on-failure" ? " KeepAlive\n \n SuccessfulExit\n \n \n" : ""; - return ` - - - -${plistString("Label", service.launchdLabel)}${plistProgramArguments(service)}${workingDirectory}${plistEnvironment(service.environment)} RunAtLoad - -${keepAlive}${plistString("StandardOutPath", launchdLogPath(service))}${plistString("StandardErrorPath", launchdLogPath(service))} - -`; +function installConfigPath(options: InstallOptions): string { + return options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config); } -async function writeInitialConfig(options: InstallOptions): Promise { - const configPath = options.config === undefined ? defaultPiWebConfigPath() : resolve(options.config); +async function writeInitialConfig(options: InstallOptions, configPath: string): Promise { await mkdir(dirname(configPath), { recursive: true }); if (!existsSync(configPath)) { await writeFile(configPath, examplePiWebConfig({ host: options.host, port: Number(options.port) })); } - return configPath; } function systemdServicePath(ref: ServiceRef): string { @@ -546,8 +392,8 @@ function installedServiceRefs(backend: ServiceBackend): ServiceRef[] { return installed.length === 0 ? productionServiceRefs() : installed; } -async function installSystemdServices(services: ServiceDefinition[]): Promise { - const selected = new Set(services.map((service) => service.id)); +async function installSystemdServices(plan: NativeServicePlan): Promise { + const selected = new Set(plan.services.map((service) => service.id)); const obsolete = stopOrder(allServiceRefs().filter((ref) => !selected.has(ref.id))); for (const ref of obsolete) { @@ -556,11 +402,11 @@ async function installSystemdServices(services: ServiceDefinition[]): Promise service.systemdName); + const names = plan.services.map((service) => service.manager.systemdName); run("systemctl", ["--user", "daemon-reload"], { check: true }); run("systemctl", ["--user", "enable", ...names], { check: true }); run("systemctl", ["--user", "restart", ...names], { check: true }); @@ -592,8 +438,8 @@ function launchdStart(ref: ServiceRef): void { run("launchctl", ["kickstart", launchdServiceTarget(ref)], { check: true }); } -async function installLaunchdServices(services: ServiceDefinition[]): Promise { - const selected = new Set(services.map((service) => service.id)); +async function installLaunchdServices(plan: NativeServicePlan): Promise { + const selected = new Set(plan.services.map((service) => service.id)); await mkdir(launchdServiceDir, { recursive: true }); await mkdir(logDir, { recursive: true }); @@ -604,16 +450,21 @@ async function installLaunchdServices(services: ServiceDefinition[]): Promise { - if (backend.kind === "systemd") await installSystemdServices(services); - else await installLaunchdServices(services); +async function installNativeServices(plan: NativeServicePlan): Promise { + if (plan.backend.kind === "systemd") await installSystemdServices(plan); + else await installLaunchdServices(plan); +} + +function serviceRefFromPlan(id: ServiceId, manager: NativeServiceManagerRef): ServiceRef { + return { id, ...manager }; } async function uninstallSystemdServices(): Promise { @@ -759,28 +610,77 @@ function baseShellChecks(backend: ServiceBackend): Check[] { return checks; } -function devInstallChecks(backend: ServiceBackend, root: string): Check[] { - const shell = serviceShellLabel(); - const checks: Check[] = [ - [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"), root)], - [`${shell} can find bash`, serviceShellCommand(commandCheck("bash"), root)], - ]; - if (backend.kind === "systemd") { - checks.push( - [`systemd user ${shell} can find npm`, systemdUserServiceShellCommand(commandCheck("npm"), root)], - [`systemd user ${shell} can find bash`, systemdUserServiceShellCommand(commandCheck("bash"), root)], - ); - } - 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 installPreflightChecks(backend: ServiceBackend, mode: InstallMode, executables: ServiceExecutables | undefined, devRoot: string | undefined): Check[] { - return [ - ...backendAvailabilityChecks(backend), - ...baseShellChecks(backend), - ...(mode === "dev" && devRoot !== undefined ? devInstallChecks(backend, devRoot) : []), - ...(mode === "production" && executables !== undefined ? [...executables.web.checks, ...executables.sessiond.checks] : []), - ]; +function nativeServiceInstallCandidate( + options: InstallOptions, + backend: ServiceBackend, + configPath: string, + devRoot: string | undefined, +): NativeServiceInstallCandidate { + const common = { + backend, + shell: detectServiceShell(), + 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"), + }, + }, + }, + }; + } + + const root = devRoot ?? devRootPath(); + return { + mode: "development", + input: { + ...common, + workingDirectory: root, + packageJsonPath: join(root, "package.json"), + }, + }; +} + +function printNativeServiceInstallFailure(failure: NativeServiceInstallFailure): void { + if (failure.kind === "plan-resolution") { + for (const item of failure.failures) { + if (item.kind === "probe-infrastructure") { + console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`); + } else if (item.kind === "entrypoint-inspection-failure") { + console.log(`✗ Could not inspect bundled ${item.serviceId} entrypoint ${item.entrypointPath}: ${item.message}`); + } else { + console.log(`✗ ${item.namedCommand} is unavailable to the service manager, and bundled entrypoint ${item.bundledEntrypointPath} is missing.`); + if (item.namedCommandFailure !== null) console.log(` ${item.namedCommandFailure}`); + } + } + return; + } + + for (const item of failure.failures) { + if (item.kind === "probe-infrastructure") { + console.log(`✗ Service-manager probe infrastructure failure (${item.reason}): ${item.message}`); + } else { + console.log(`✗ ${item.prerequisite.description}`); + if (item.detail !== null && item.detail !== item.prerequisite.description) console.log(` ${item.detail}`); + } + } } async function install(args: string[]): Promise { @@ -788,23 +688,24 @@ async function install(args: string[]): Promise { const options = parseInstallOptions(args); const devRoot = options.mode === "dev" ? devRootPath() : undefined; if (devRoot !== undefined) validateDevCheckout(devRoot); + const configPath = installConfigPath(options); + const candidate = nativeServiceInstallCandidate(options, backend, configPath, devRoot); - const executables = options.mode === "production" ? resolveServiceExecutables(backend) : undefined; console.log(`Running PI WEB ${options.mode} install preflight checks...`); console.log(`Service backend: ${backend.label}`); console.log(`Service shell: ${describeServiceShell()}`); - if (!runChecks(installPreflightChecks(backend, options.mode, executables, devRoot))) { + const result = await installNativeServiceCandidate(candidate, { + probe: createNativeServiceAuthoritativeProbe(), + fileExists: existsSync, + writeInitialConfig: () => writeInitialConfig(options, configPath), + replaceServices: installNativeServices, + }); + if (!result.ok) { + printNativeServiceInstallFailure(result.failure); printPathSetupAdvice(); - throw new Error("Install preflight checks failed. Fix the failed checks above, then run `pi-web doctor` for more detail."); + 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 configPath = await writeInitialConfig(options); - const services = options.mode === "dev" - ? devServiceDefinitions(options, configPath, devRoot ?? devRootPath()) - : productionServiceDefinitions(options, configPath, executables ?? resolveServiceExecutables(backend)); - - await installNativeServices(backend, services); - console.log(`\nPI WEB ${options.mode} services are installed and starting.`); console.log(`Config: ${configPath}`); if (options.mode === "dev") { diff --git a/src/nativeServices/serviceInstall.test.ts b/src/nativeServices/serviceInstall.test.ts new file mode 100644 index 0000000..116a9fb --- /dev/null +++ b/src/nativeServices/serviceInstall.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; +import { installNativeServiceCandidate } from "./serviceInstall.js"; +import type { + NativeServiceAuthoritativeProbe, + NativeServicePlan, + NativeServiceProbeRequest, + ProductionNativeServicePlanInput, +} from "./servicePlan.js"; + +const productionInput: ProductionNativeServicePlanInput = { + backend: { kind: "systemd", label: "systemd user services" }, + shell: { + name: "bash", + executable: "/bin/bash", + source: "detected", + detectedExecutable: "/bin/bash", + }, + environment: { PI_WEB_CONFIG: "/home/user/config.json" }, + executables: { + sessiond: { + configuredCommand: undefined, + namedCommand: "pi-web-sessiond", + bundledEntrypointPath: "/package/sessiond.js", + }, + web: { + configuredCommand: undefined, + namedCommand: "pi-web-server", + bundledEntrypointPath: "/package/server.js", + }, + }, +}; + +function successfulProbe(events: string[]): NativeServiceAuthoritativeProbe { + return { + run: (request) => { + events.push(`probe:${request.purpose}`); + return Promise.resolve({ + kind: "completed", + outcomes: request.prerequisites.map((prerequisite) => ({ + prerequisiteId: prerequisite.id, + status: "satisfied" as const, + detail: null, + })), + }); + }, + }; +} + +describe("native service install orchestration", () => { + it("resolves and validates the complete plan before writing config or replacing services", async () => { + const events: string[] = []; + const writeInitialConfig = vi.fn(() => { events.push("write-config"); return Promise.resolve(); }); + const replaceServices = vi.fn((plan: NativeServicePlan) => { + events.push(`replace:${plan.mode}`); + return Promise.resolve(); + }); + + const result = await installNativeServiceCandidate( + { mode: "production", input: productionInput }, + { + probe: successfulProbe(events), + fileExists: () => false, + writeInitialConfig, + replaceServices, + }, + ); + + expect(result.ok).toBe(true); + expect(events).toEqual([ + "probe:executable-selection", + "probe:plan-validation", + "write-config", + "replace:production", + ]); + expect(writeInitialConfig).toHaveBeenCalledOnce(); + expect(replaceServices).toHaveBeenCalledWith(expect.objectContaining({ mode: "production" })); + }); + + it("does not make durable changes when exact plan requirements are unsatisfied", async () => { + const writeInitialConfig = vi.fn<() => Promise>(() => Promise.resolve()); + const replaceServices = vi.fn<() => Promise>(() => Promise.resolve()); + const probe: NativeServiceAuthoritativeProbe = { + run: (request: NativeServiceProbeRequest) => Promise.resolve({ + kind: "completed", + outcomes: request.prerequisites.map((prerequisite) => ({ + prerequisiteId: prerequisite.id, + status: request.purpose === "plan-validation" ? "unsatisfied" : "satisfied", + detail: "not visible in the service manager environment", + })), + }), + }; + + const result = await installNativeServiceCandidate( + { mode: "production", input: productionInput }, + { probe, fileExists: () => false, writeInitialConfig, replaceServices }, + ); + + expect(result).toMatchObject({ ok: false, failure: { kind: "plan-validation" } }); + if (result.ok || result.failure.kind !== "plan-validation") throw new Error("Expected validation failure"); + expect(result.failure.failures).not.toHaveLength(0); + expect(result.failure.failures.every((failure) => failure.kind === "prerequisite-unsatisfied")).toBe(true); + expect(writeInitialConfig).not.toHaveBeenCalled(); + expect(replaceServices).not.toHaveBeenCalled(); + }); + + it("does not mislabel probe infrastructure failures or write anything", async () => { + const writeInitialConfig = vi.fn<() => Promise>(() => Promise.resolve()); + const replaceServices = vi.fn<() => Promise>(() => Promise.resolve()); + + const result = await installNativeServiceCandidate( + { mode: "production", input: productionInput }, + { + probe: { + run: () => Promise.resolve({ + kind: "infrastructure-failure", + reason: "timeout", + message: "service manager probe timed out", + }), + }, + fileExists: () => true, + writeInitialConfig, + replaceServices, + }, + ); + + expect(result).toEqual({ + ok: false, + failure: { + kind: "plan-resolution", + failures: [{ + kind: "probe-infrastructure", + serviceIds: ["sessiond", "web"], + reason: "timeout", + message: "service manager probe timed out", + }], + }, + }); + expect(writeInitialConfig).not.toHaveBeenCalled(); + expect(replaceServices).not.toHaveBeenCalled(); + }); +}); diff --git a/src/nativeServices/serviceInstall.ts b/src/nativeServices/serviceInstall.ts new file mode 100644 index 0000000..108354e --- /dev/null +++ b/src/nativeServices/serviceInstall.ts @@ -0,0 +1,60 @@ +import { + createDevelopmentNativeServicePlan, + resolveProductionNativeServicePlan, + validateNativeServicePlan, + type DevelopmentNativeServicePlanInput, + type NativeServiceAuthoritativeProbe, + type NativeServicePlan, + type NativeServicePlanDependencies, + type NativeServicePlanFailure, + type NativeServicePlanValidationFailure, + type ProductionNativeServicePlanInput, +} from "./servicePlan.js"; + +export type NativeServiceInstallCandidate = + | { mode: "production"; input: ProductionNativeServicePlanInput } + | { mode: "development"; input: DevelopmentNativeServicePlanInput }; + +export interface NativeServiceInstallDependencies extends NativeServicePlanDependencies { + probe: NativeServiceAuthoritativeProbe; + writeInitialConfig(): Promise; + replaceServices(plan: NativeServicePlan): Promise; +} + +export type NativeServiceInstallFailure = + | { kind: "plan-resolution"; failures: readonly NativeServicePlanFailure[] } + | { kind: "plan-validation"; failures: readonly NativeServicePlanValidationFailure[] }; + +export type NativeServiceInstallResult = + | { ok: true; plan: NativeServicePlan } + | { ok: false; failure: NativeServiceInstallFailure }; + +/** + * Keeps preflight effects ahead of durable install effects. The authoritative + * probes may create bounded temporary artifacts, but they must clean those up + * before this function writes config or replaces existing services. + */ +export async function installNativeServiceCandidate( + candidate: NativeServiceInstallCandidate, + dependencies: NativeServiceInstallDependencies, +): Promise { + let plan: NativeServicePlan; + if (candidate.mode === "production") { + const resolution = await resolveProductionNativeServicePlan(candidate.input, dependencies); + if (!resolution.ok) { + return { ok: false, failure: { kind: "plan-resolution", failures: resolution.failures } }; + } + plan = resolution.plan; + } else { + plan = createDevelopmentNativeServicePlan(candidate.input); + } + + const validation = await validateNativeServicePlan(plan, dependencies.probe); + if (!validation.ok) { + return { ok: false, failure: { kind: "plan-validation", failures: validation.failures } }; + } + + await dependencies.writeInitialConfig(); + await dependencies.replaceServices(plan); + return { ok: true, plan }; +} diff --git a/src/nativeServices/servicePlan.test.ts b/src/nativeServices/servicePlan.test.ts index f09f097..4560bf5 100644 --- a/src/nativeServices/servicePlan.test.ts +++ b/src/nativeServices/servicePlan.test.ts @@ -123,14 +123,12 @@ describe("production native service planning", () => { backend, shell, workingDirectory: null, - prerequisites: [{ id: "sessiond.command.pi-web-sessiond" }, { id: "sessiond.node" }], - }, - { - purpose: "plan-validation", - backend, - shell, - workingDirectory: null, - prerequisites: [{ id: "web.command.pi-web-server" }, { id: "web.node" }], + prerequisites: [ + { id: "sessiond.command.pi-web-sessiond" }, + { id: "sessiond.node" }, + { id: "web.command.pi-web-server" }, + { id: "web.node" }, + ], }, ]); }); @@ -253,7 +251,7 @@ describe("production native service planning", () => { const fileExists = vi.fn<(path: string) => boolean>(() => true); const resolution = await resolveProductionNativeServicePlan(productionInput(), { probe: { - run: () => Promise.resolve({ kind: "infrastructure-failure", message: "launchd probe cleanup failed" }), + run: () => Promise.resolve({ kind: "infrastructure-failure", reason: "cleanup", message: "launchd probe cleanup failed" }), }, fileExists, }); @@ -264,6 +262,7 @@ describe("production native service planning", () => { failures: [{ kind: "probe-infrastructure", serviceIds: ["sessiond", "web"], + reason: "cleanup", message: "launchd probe cleanup failed", }], }); @@ -276,7 +275,7 @@ describe("production native service planning", () => { }); expect(thrown).toMatchObject({ ok: false, - failures: [{ kind: "probe-infrastructure", message: "systemd-run failed" }], + failures: [{ kind: "probe-infrastructure", reason: "manager", message: "systemd-run failed" }], }); const malformed = await resolveProductionNativeServicePlan(productionInput(), { @@ -290,7 +289,7 @@ describe("production native service planning", () => { }); expect(malformed).toMatchObject({ ok: false, - failures: [{ kind: "probe-infrastructure", message: "Authoritative probe returned no outcome for web.command.pi-web-server." }], + failures: [{ kind: "probe-infrastructure", reason: "malformed-output", message: "Authoritative probe returned no outcome for web.command.pi-web-server." }], }); }); }); @@ -349,8 +348,19 @@ describe("development native service planning", () => { expect(serviceCommandRequirements).not.toContain("pi-web-sessiond"); expect(planValidationProbeRequests(plan)).toMatchObject([ - { backend: { kind: "launchd" }, workingDirectory: "/checkout with space" }, - { backend: { kind: "launchd" }, workingDirectory: "/checkout with space" }, + { + backend: { kind: "launchd" }, + workingDirectory: "/checkout with space", + prerequisites: [ + { id: "sessiond.node" }, + { id: "sessiond.command.npm" }, + { id: "sessiond.package-scripts" }, + { id: "uiDev.node" }, + { id: "uiDev.command.npm" }, + { id: "uiDev.command.bash" }, + { id: "uiDev.package-scripts" }, + ], + }, ]); }); }); diff --git a/src/nativeServices/servicePlan.ts b/src/nativeServices/servicePlan.ts index f0d3404..0a20a00 100644 --- a/src/nativeServices/servicePlan.ts +++ b/src/nativeServices/servicePlan.ts @@ -4,6 +4,7 @@ export type NativeServiceId = "sessiond" | "web" | "uiDev"; export type ProductionNativeServiceId = Extract; export type NativeServiceShellName = "bash" | "zsh" | "fish"; export type NativeServiceRestartPolicy = "on-failure" | "never"; +export type NativeServiceProbeInfrastructureReason = "manager" | "timeout" | "malformed-output" | "cleanup"; export interface NativeServiceBackend { kind: NativeServiceBackendKind; @@ -123,6 +124,7 @@ export type NativeServiceProbeResult = } | { kind: "infrastructure-failure"; + reason: NativeServiceProbeInfrastructureReason; message: string; }; @@ -167,6 +169,7 @@ export type NativeServicePlanFailure = | { kind: "probe-infrastructure"; serviceIds: readonly ProductionNativeServiceId[]; + reason: NativeServiceProbeInfrastructureReason; message: string; } | { @@ -187,7 +190,23 @@ export type NativeServicePlanResolution = | { ok: true; plan: NativeServicePlan } | { ok: false; failures: readonly NativeServicePlanFailure[] }; -const nativeServiceRefs: Readonly> = { +export type NativeServicePlanValidationFailure = + | { + kind: "prerequisite-unsatisfied"; + prerequisite: NativeServicePrerequisite; + detail: string | null; + } + | { + kind: "probe-infrastructure"; + reason: NativeServiceProbeInfrastructureReason; + message: string; + }; + +export type NativeServicePlanValidation = + | { ok: true } + | { ok: false; failures: readonly NativeServicePlanValidationFailure[] }; + +export const nativeServiceManagerRefs: Readonly> = { sessiond: { systemdName: "pi-web-sessiond.service", launchdLabel: "com.pi-web.sessiond", @@ -208,7 +227,7 @@ const nativeServiceRefs: Readonly productionService(input, serviceId, requiredStrategy(strategies, serviceId))), + services: productionNativeServiceIds.map((serviceId) => productionService(input, serviceId, requiredStrategy(strategies, serviceId))), }, }; } @@ -327,7 +346,7 @@ export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServi services: [ { id: "sessiond", - manager: nativeServiceRefs.sessiond, + manager: nativeServiceManagerRefs.sessiond, description: "PI WEB session daemon (dev)", shellCommand: "exec npm run start:sessiond", strategy: { kind: "development-npm-script", script: "start:sessiond" }, @@ -344,7 +363,7 @@ export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServi }, { id: "uiDev", - manager: nativeServiceRefs.uiDev, + manager: nativeServiceManagerRefs.uiDev, description: "PI WEB UI dev server", shellCommand: `exec /usr/bin/env bash -c ${shellSingleQuote(input.shell.name, uiDevCommand)}`, strategy: { kind: "development-npm-script-group", scripts: uiDevScripts, interpreter: "bash" }, @@ -365,14 +384,64 @@ export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServi } export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] { - return plan.services.flatMap((service) => service.prerequisites.length === 0 ? [] : [{ - purpose: "plan-validation" as const, - backend: plan.backend, - shell: plan.shell, - environment: service.environment, - workingDirectory: service.workingDirectory, - prerequisites: service.prerequisites, - }]); + const requests: (Omit & { prerequisites: NativeServicePrerequisite[] })[] = []; + for (const service of plan.services) { + if (service.prerequisites.length === 0) continue; + const existing = requests.find((request) => + request.workingDirectory === service.workingDirectory + && environmentsEqual(request.environment, service.environment)); + if (existing === undefined) { + requests.push({ + purpose: "plan-validation", + backend: plan.backend, + shell: plan.shell, + environment: service.environment, + workingDirectory: service.workingDirectory, + prerequisites: [...service.prerequisites], + }); + continue; + } + existing.prerequisites.push(...service.prerequisites); + } + return requests; +} + +export async function validateNativeServicePlan( + plan: NativeServicePlan, + probe: NativeServiceAuthoritativeProbe, +): Promise { + const failures: NativeServicePlanValidationFailure[] = []; + for (const request of planValidationProbeRequests(plan)) { + let result: NativeServiceProbeResult; + try { + result = await probe.run(request); + } catch (error: unknown) { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", reason: "manager", message: errorMessage(error) }], + }; + } + if (result.kind === "infrastructure-failure") { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", reason: result.reason, message: result.message }], + }; + } + const parsed = probeOutcomes(request.prerequisites, result.outcomes); + if (parsed.kind === "infrastructure-failure") { + return { + ok: false, + failures: [{ kind: "probe-infrastructure", reason: parsed.reason, message: parsed.message }], + }; + } + for (const prerequisite of request.prerequisites) { + const outcome = parsed.outcomes.get(prerequisite.id); + if (outcome?.status === "unsatisfied") { + failures.push({ kind: "prerequisite-unsatisfied", prerequisite, detail: outcome.detail }); + } + } + } + return failures.length === 0 ? { ok: true } : { ok: false, failures }; } function productionService( @@ -383,7 +452,7 @@ function productionService( const isWeb = serviceId === "web"; return { id: serviceId, - manager: nativeServiceRefs[serviceId], + manager: nativeServiceManagerRefs[serviceId], description: isWeb ? "PI WEB server" : "PI WEB session daemon", shellCommand: `exec ${strategyCommand(input.shell, strategy)}`, strategy, @@ -439,30 +508,30 @@ async function runSelectionProbe( prerequisites, }); } catch (error: unknown) { - return { kind: "infrastructure-failure", message: errorMessage(error) }; + return { kind: "infrastructure-failure", reason: "manager", message: errorMessage(error) }; } } function probeOutcomes( prerequisites: readonly NativeServicePrerequisite[], outcomes: readonly NativeServicePrerequisiteOutcome[], -): { kind: "completed"; outcomes: Map } | { kind: "infrastructure-failure"; message: string } { +): { kind: "completed"; outcomes: Map } | { kind: "infrastructure-failure"; reason: "malformed-output"; message: string } { const expectedIds = new Set(prerequisites.map((prerequisite) => prerequisite.id)); const byId = new Map(); for (const outcome of outcomes) { if (!expectedIds.has(outcome.prerequisiteId)) { - return { kind: "infrastructure-failure", message: `Authoritative probe returned unexpected outcome ${outcome.prerequisiteId}.` }; + return { kind: "infrastructure-failure", reason: "malformed-output", message: `Authoritative probe returned unexpected outcome ${outcome.prerequisiteId}.` }; } if (byId.has(outcome.prerequisiteId)) { - return { kind: "infrastructure-failure", message: `Authoritative probe returned duplicate outcome ${outcome.prerequisiteId}.` }; + return { kind: "infrastructure-failure", reason: "malformed-output", message: `Authoritative probe returned duplicate outcome ${outcome.prerequisiteId}.` }; } byId.set(outcome.prerequisiteId, outcome); } const missing = prerequisites.find((prerequisite) => !byId.has(prerequisite.id)); if (missing !== undefined) { - return { kind: "infrastructure-failure", message: `Authoritative probe returned no outcome for ${missing.id}.` }; + return { kind: "infrastructure-failure", reason: "malformed-output", message: `Authoritative probe returned no outcome for ${missing.id}.` }; } return { kind: "completed", outcomes: byId }; } @@ -535,6 +604,16 @@ function copyEnvironment(environment: Readonly>): Readonl return { ...environment }; } +function environmentsEqual( + left: Readonly>, + right: Readonly>, +): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + return leftEntries.length === rightEntries.length + && leftEntries.every(([key, value]) => right[key] === value); +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/nativeServices/serviceProbe.test.ts b/src/nativeServices/serviceProbe.test.ts new file mode 100644 index 0000000..122e4bb --- /dev/null +++ b/src/nativeServices/serviceProbe.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it, vi } from "vitest"; +import { + LaunchdNativeServiceProbe, + SystemdNativeServiceProbe, + launchdProbePlist, + systemdRunArguments, + type LaunchdProbeFileSystem, + type ProbeCommandResult, + type ProbeCommandRunner, +} from "./serviceProbe.js"; +import type { NativeServiceProbeRequest } from "./servicePlan.js"; + +function request(kind: "systemd" | "launchd" = "systemd"): NativeServiceProbeRequest { + return { + purpose: "plan-validation", + backend: { kind, label: kind }, + shell: { + name: "zsh", + executable: "/bin/zsh", + source: "detected", + detectedExecutable: "/bin/zsh", + }, + environment: { PI_WEB_CONFIG: "/home/user/config with space.json" }, + workingDirectory: "/checkout with space", + prerequisites: [{ + id: "sessiond.command.npm", + kind: "command-available", + command: "npm", + description: "npm is available", + }], + }; +} + +function completed(status = 0, stdout = "", stderr = ""): ProbeCommandResult { + return { kind: "completed", status, stdout, stderr }; +} + +function marker(id: string, status: "satisfied" | "unsatisfied"): string { + return `PI_WEB_PROBE_fixed\t${Buffer.from(id).toString("base64")}\t${status}\n`; +} + +function queuedRunner(results: ProbeCommandResult[]): ProbeCommandRunner & { calls: { command: string; args: readonly string[]; timeoutMs: number }[] } { + const calls: { command: string; args: readonly string[]; timeoutMs: number }[] = []; + return { + calls, + run: (command, args, timeoutMs) => { + calls.push({ command, args, timeoutMs }); + const result = results.shift(); + if (result === undefined) throw new Error(`Unexpected command: ${command} ${args.join(" ")}`); + return Promise.resolve(result); + }, + }; +} + +describe("systemd authoritative native-service probe", () => { + it("runs the exact shell, environment, and cwd in a transient user service", async () => { + const runner = queuedRunner([completed(0, `login banner\n${marker("sessiond.command.npm", "satisfied")}`)]); + const probe = new SystemdNativeServiceProbe({ + commandRunner: runner, + createUniqueId: () => "fixed", + commandTimeoutMs: 3210, + }); + + await expect(probe.run(request())).resolves.toEqual({ + kind: "completed", + outcomes: [{ prerequisiteId: "sessiond.command.npm", status: "satisfied", detail: null }], + }); + expect(runner.calls).toHaveLength(1); + expect(runner.calls[0]).toMatchObject({ command: "systemd-run", timeoutMs: 3210 }); + expect(runner.calls[0]?.args).toEqual([ + "--user", + "--wait", + "--collect", + "--pipe", + "--quiet", + "--unit=pi-web-authoritative-probe-fixed.service", + "--setenv=PI_WEB_CONFIG=/home/user/config with space.json", + "--working-directory=/checkout with space", + "/usr/bin/env", + "/bin/zsh", + "-lc", + expect.stringContaining("command -v 'npm'"), + ]); + }); + + it("reports requirement failures as completed and malformed output as infrastructure", async () => { + const unsatisfiedRunner = queuedRunner([completed(0, marker("sessiond.command.npm", "unsatisfied"))]); + const dependencies = { commandRunner: unsatisfiedRunner, createUniqueId: () => "fixed", commandTimeoutMs: 100 }; + await expect(new SystemdNativeServiceProbe(dependencies).run(request())).resolves.toEqual({ + kind: "completed", + outcomes: [{ + prerequisiteId: "sessiond.command.npm", + status: "unsatisfied", + detail: "npm was not found in the native service environment.", + }], + }); + + const malformedRunner = queuedRunner([completed(0, "no marker here")]); + await expect(new SystemdNativeServiceProbe({ ...dependencies, commandRunner: malformedRunner }).run(request())).resolves.toMatchObject({ + kind: "infrastructure-failure", + reason: "malformed-output", + }); + }); + + it("bounds a hung unit and distinguishes cleanup failure", async () => { + const runner = queuedRunner([ + { kind: "timeout", stdout: "", stderr: "" }, + completed(0), + completed(1, "", "unit still loaded"), + ]); + const probe = new SystemdNativeServiceProbe({ + commandRunner: runner, + createUniqueId: () => "fixed", + commandTimeoutMs: 100, + }); + + const result = await probe.run(request()); + expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "cleanup" }); + expect(result.kind === "infrastructure-failure" && result.message).toContain("unit still loaded"); + expect(runner.calls.map(({ command, args }) => [command, ...args.slice(0, 3)])).toEqual([ + ["systemd-run", "--user", "--wait", "--collect"], + ["systemctl", "--user", "stop", "pi-web-authoritative-probe-fixed.service"], + ["systemctl", "--user", "reset-failed", "pi-web-authoritative-probe-fixed.service"], + ]); + }); +}); + +describe("launchd authoritative native-service probe", () => { + it("bootstraps a uniquely labelled one-shot agent in gui/ and always cleans it up", async () => { + const runner = queuedRunner([ + completed(0), + completed(0, "state = running\n"), + completed(0, "state = not running\nlast exit code = 0\n"), + completed(0), + ]); + const fileSystem = launchdFileSystem({ + "/tmp/probe/stdout.log": marker("sessiond.command.npm", "satisfied"), + "/tmp/probe/stderr.log": "", + }); + let now = 0; + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 501, + createUniqueId: () => "fixed", + now: () => now, + sleep: (milliseconds) => { now += milliseconds; return Promise.resolve(); }, + probeTimeoutMs: 500, + pollIntervalMs: 10, + commandTimeoutMs: 100, + }); + + await expect(probe.run(request("launchd"))).resolves.toMatchObject({ kind: "completed" }); + expect(runner.calls.map(({ command, args }) => [command, ...args])).toEqual([ + ["launchctl", "bootstrap", "gui/501", "/tmp/probe/probe.plist"], + ["launchctl", "print", "gui/501/com.pi-web.authoritative-probe.501.fixed"], + ["launchctl", "print", "gui/501/com.pi-web.authoritative-probe.501.fixed"], + ["launchctl", "bootout", "gui/501/com.pi-web.authoritative-probe.501.fixed"], + ]); + expect(fileSystem.writeFile).toHaveBeenCalledWith( + "/tmp/probe/probe.plist", + expect.stringContaining("/bin/zsh"), + ); + expect(fileSystem.writeFile).toHaveBeenCalledWith( + "/tmp/probe/probe.plist", + expect.stringContaining("WorkingDirectory\n /checkout with space"), + ); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); + + it("times out deterministically, boots out the agent, and removes temporary files", async () => { + const runner = queuedRunner([ + completed(0), + completed(0, "state = running\n"), + completed(0, "state = running\n"), + completed(0), + ]); + const fileSystem = launchdFileSystem({}); + let now = 0; + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 502, + createUniqueId: () => "fixed", + now: () => now, + sleep: (milliseconds) => { now += milliseconds; return Promise.resolve(); }, + probeTimeoutMs: 20, + pollIntervalMs: 10, + commandTimeoutMs: 100, + }); + + await expect(probe.run(request("launchd"))).resolves.toMatchObject({ + kind: "infrastructure-failure", + reason: "timeout", + }); + expect(runner.calls.at(-1)).toMatchObject({ + command: "launchctl", + args: ["bootout", "gui/502/com.pi-web.authoritative-probe.502.fixed"], + }); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); + + it("surfaces cleanup failure instead of returning an otherwise successful probe", async () => { + const runner = queuedRunner([ + completed(0), + completed(0, "state = not running\nlast exit code = 0\n"), + completed(1, "", "bootout denied"), + ]); + const fileSystem = launchdFileSystem({ + "/tmp/probe/stdout.log": marker("sessiond.command.npm", "satisfied"), + "/tmp/probe/stderr.log": "", + }); + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 503, + 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("bootout denied"); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); + + it("checks and boots out a label when bootstrap itself times out", async () => { + const runner = queuedRunner([ + { kind: "timeout", stdout: "", stderr: "" }, + completed(0, "state = running\n"), + completed(0), + ]); + const fileSystem = launchdFileSystem({}); + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 504, + 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: "timeout", + }); + expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "print", "bootout"]); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); + + it("removes temporary files when bootstrap fails without booting out an unloaded label", async () => { + const runner = queuedRunner([completed(1, "", "bootstrap denied")]); + const fileSystem = launchdFileSystem({}); + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 504, + 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("bootstrap denied"); + expect(runner.calls).toHaveLength(1); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); +}); + +describe("probe service definitions", () => { + it("renders backend inputs without inheriting the caller PATH", () => { + const probeRequest = request(); + expect(systemdRunArguments(probeRequest, "probe.service", "echo ok")).not.toContain(expect.stringContaining("PATH=")); + const plist = launchdProbePlist(probeRequest, "com.example.probe", "echo ok", "/tmp/out", "/tmp/err"); + expect(plist).not.toContain("PATH"); + expect(plist).toContain("PI_WEB_CONFIG"); + }); +}); + +function launchdFileSystem(contents: Record): LaunchdProbeFileSystem & { + writeFile: ReturnType>; + removeDirectory: ReturnType>; +} { + const writeFileMock = vi.fn(() => Promise.resolve()); + const removeDirectoryMock = vi.fn(() => Promise.resolve()); + return { + createTemporaryDirectory: () => Promise.resolve("/tmp/probe"), + writeFile: writeFileMock, + readFile: (path) => { + const content = contents[path]; + return content === undefined ? Promise.reject(new Error(`missing ${path}`)) : Promise.resolve(content); + }, + removeDirectory: removeDirectoryMock, + }; +} diff --git a/src/nativeServices/serviceProbe.ts b/src/nativeServices/serviceProbe.ts new file mode 100644 index 0000000..3f47624 --- /dev/null +++ b/src/nativeServices/serviceProbe.ts @@ -0,0 +1,519 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir, userInfo } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { + NativeServiceAuthoritativeProbe, + NativeServicePrerequisite, + NativeServicePrerequisiteOutcome, + NativeServiceProbeRequest, + NativeServiceProbeResult, + NativeServiceShellName, +} from "./servicePlan.js"; + +export type ProbeCommandResult = + | { kind: "completed"; status: number; stdout: string; stderr: string } + | { kind: "timeout"; stdout: string; stderr: string } + | { kind: "spawn-failure"; message: string; stdout: string; stderr: string }; + +export interface ProbeCommandRunner { + run(command: string, args: readonly string[], timeoutMs: number): Promise; +} + +export interface LaunchdProbeFileSystem { + createTemporaryDirectory(prefix: string): Promise; + writeFile(path: string, contents: string): Promise; + readFile(path: string): Promise; + removeDirectory(path: string): Promise; +} + +interface CommonProbeDependencies { + commandRunner: ProbeCommandRunner; + createUniqueId(): string; + commandTimeoutMs: number; +} + +export type SystemdProbeDependencies = CommonProbeDependencies; + +export interface LaunchdProbeDependencies extends CommonProbeDependencies { + fileSystem: LaunchdProbeFileSystem; + uid: number; + now(): number; + sleep(milliseconds: number): Promise; + probeTimeoutMs: number; + pollIntervalMs: number; +} + +const defaultCommandTimeoutMs = 15_000; +const defaultProbeTimeoutMs = 15_000; +const defaultPollIntervalMs = 50; + +export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProbe { + public constructor(private readonly dependencies: SystemdProbeDependencies) {} + + public async run(request: NativeServiceProbeRequest): Promise { + if (request.backend.kind !== "systemd") { + return infrastructureFailure("manager", `Systemd probe cannot validate the ${request.backend.kind} backend.`); + } + + const uniqueId = safeUniqueId(this.dependencies.createUniqueId()); + const unitName = `pi-web-authoritative-probe-${uniqueId}.service`; + const outputPrefix = `PI_WEB_PROBE_${uniqueId}`; + const command = prerequisiteProbeCommand(request.shell.name, request.prerequisites, outputPrefix); + const args = systemdRunArguments(request, unitName, command); + const result = await this.dependencies.commandRunner.run("systemd-run", args, this.dependencies.commandTimeoutMs); + + if (result.kind === "timeout") { + const cleanupFailure = await this.cleanupTimedOutUnit(unitName); + return cleanupFailure ?? infrastructureFailure( + "timeout", + `Timed out waiting for transient systemd unit ${unitName}.`, + ); + } + if (result.kind === "spawn-failure") { + return infrastructureFailure("manager", `Could not start systemd-run: ${result.message}`); + } + if (result.status !== 0) { + return infrastructureFailure( + "manager", + `Transient systemd probe ${unitName} failed: ${firstOutput(result.stderr, result.stdout, `exit status ${String(result.status)}`)}`, + ); + } + return parseProbeOutput(result.stdout, request.prerequisites, outputPrefix); + } + + private async cleanupTimedOutUnit(unitName: string): Promise { + const stop = await this.dependencies.commandRunner.run( + "systemctl", + ["--user", "stop", unitName], + this.dependencies.commandTimeoutMs, + ); + if (stop.kind !== "completed" || stop.status !== 0) { + return infrastructureFailure("cleanup", `Could not stop timed-out transient systemd unit ${unitName}: ${commandFailureDetail(stop)}`); + } + const reset = await this.dependencies.commandRunner.run( + "systemctl", + ["--user", "reset-failed", unitName], + this.dependencies.commandTimeoutMs, + ); + if (reset.kind !== "completed" || reset.status !== 0) { + return infrastructureFailure("cleanup", `Could not collect timed-out transient systemd unit ${unitName}: ${commandFailureDetail(reset)}`); + } + return null; + } +} + +export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProbe { + public constructor(private readonly dependencies: LaunchdProbeDependencies) {} + + public async run(request: NativeServiceProbeRequest): Promise { + if (request.backend.kind !== "launchd") { + return infrastructureFailure("manager", `Launchd probe cannot validate the ${request.backend.kind} backend.`); + } + + const uniqueId = safeUniqueId(this.dependencies.createUniqueId()); + const label = `com.pi-web.authoritative-probe.${String(this.dependencies.uid)}.${uniqueId}`; + const domain = `gui/${String(this.dependencies.uid)}`; + const target = `${domain}/${label}`; + const outputPrefix = `PI_WEB_PROBE_${uniqueId}`; + let directory: string | null = null; + let bootstrapState: "not-loaded" | "loaded" | "uncertain" = "not-loaded"; + let result: NativeServiceProbeResult; + + try { + directory = await this.dependencies.fileSystem.createTemporaryDirectory( + join(tmpdir(), "pi-web-launchd-probe-"), + ); + const plistPath = join(directory, "probe.plist"); + const stdoutPath = join(directory, "stdout.log"); + const stderrPath = join(directory, "stderr.log"); + const command = prerequisiteProbeCommand(request.shell.name, request.prerequisites, outputPrefix); + await this.dependencies.fileSystem.writeFile( + plistPath, + launchdProbePlist(request, label, command, stdoutPath, stderrPath), + ); + + const bootstrap = await this.dependencies.commandRunner.run( + "launchctl", + ["bootstrap", domain, plistPath], + this.dependencies.commandTimeoutMs, + ); + if (bootstrap.kind !== "completed" || bootstrap.status !== 0) { + bootstrapState = bootstrap.kind === "timeout" ? "uncertain" : "not-loaded"; + result = commandInfrastructureFailure("bootstrap launchd probe", bootstrap); + } else { + bootstrapState = "loaded"; + result = await this.waitForResult(target, stdoutPath, stderrPath, request.prerequisites, outputPrefix); + } + } catch (error: unknown) { + result = infrastructureFailure("manager", `Could not prepare launchd probe: ${errorMessage(error)}`); + } + + const cleanupFailure = await this.cleanup(target, directory, bootstrapState); + return cleanupFailure ?? result; + } + + private async waitForResult( + target: string, + stdoutPath: string, + stderrPath: string, + prerequisites: readonly NativeServicePrerequisite[], + outputPrefix: string, + ): Promise { + const deadline = this.dependencies.now() + this.dependencies.probeTimeoutMs; + while (this.dependencies.now() < deadline) { + const printed = await this.dependencies.commandRunner.run( + "launchctl", + ["print", target], + this.dependencies.commandTimeoutMs, + ); + if (printed.kind !== "completed" || printed.status !== 0) { + return commandInfrastructureFailure("inspect launchd probe", printed); + } + + const state = launchdField(printed.stdout, "state"); + if (state === undefined) { + return infrastructureFailure("malformed-output", `launchctl returned no state for ${target}.`); + } + const lastExitCode = launchdIntegerField(printed.stdout, "last exit code"); + if (state === "not running" && lastExitCode !== undefined) { + let stdout: string; + let stderr: string; + try { + [stdout, stderr] = await Promise.all([ + this.dependencies.fileSystem.readFile(stdoutPath), + this.dependencies.fileSystem.readFile(stderrPath), + ]); + } catch (error: unknown) { + return infrastructureFailure("manager", `Could not read launchd probe output: ${errorMessage(error)}`); + } + if (lastExitCode !== 0) { + return infrastructureFailure( + "manager", + `Launchd probe service exited with status ${String(lastExitCode)}: ${firstOutput(stderr, stdout, "no output")}`, + ); + } + return parseProbeOutput(stdout, prerequisites, outputPrefix); + } + + await this.dependencies.sleep(this.dependencies.pollIntervalMs); + } + return infrastructureFailure("timeout", `Timed out waiting for launchd probe ${target}.`); + } + + private async cleanup( + target: string, + directory: string | null, + bootstrapState: "not-loaded" | "loaded" | "uncertain", + ): Promise { + const failures: string[] = []; + let shouldBootout = bootstrapState === "loaded"; + if (bootstrapState === "uncertain") { + const inspection = await this.dependencies.commandRunner.run( + "launchctl", + ["print", target], + this.dependencies.commandTimeoutMs, + ); + if (inspection.kind === "completed") { + shouldBootout = inspection.status === 0; + } else { + // If launchctl cannot tell us whether a timed-out bootstrap loaded the + // label, bootout is the only operation that can make cleanup certain. + shouldBootout = true; + } + } + if (shouldBootout) { + const bootout = await this.dependencies.commandRunner.run( + "launchctl", + ["bootout", target], + this.dependencies.commandTimeoutMs, + ); + if (bootout.kind !== "completed" || bootout.status !== 0) { + failures.push(`bootout failed: ${commandFailureDetail(bootout)}`); + } + } + if (directory !== null) { + try { + await this.dependencies.fileSystem.removeDirectory(directory); + } catch (error: unknown) { + failures.push(`temporary-file removal failed: ${errorMessage(error)}`); + } + } + return failures.length === 0 + ? null + : infrastructureFailure("cleanup", `Launchd probe cleanup failed for ${target}: ${failures.join("; ")}`); + } +} + +export function createNativeServiceAuthoritativeProbe(): NativeServiceAuthoritativeProbe { + const commandRunner = new SpawnProbeCommandRunner(); + const common: CommonProbeDependencies = { + commandRunner, + createUniqueId: randomUUID, + commandTimeoutMs: defaultCommandTimeoutMs, + }; + const systemd = new SystemdNativeServiceProbe(common); + const launchd = new LaunchdNativeServiceProbe({ + ...common, + fileSystem: nodeLaunchdProbeFileSystem, + uid: userInfo().uid, + now: Date.now, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + probeTimeoutMs: defaultProbeTimeoutMs, + pollIntervalMs: defaultPollIntervalMs, + }); + return { + run: (request) => request.backend.kind === "systemd" ? systemd.run(request) : launchd.run(request), + }; +} + +export function systemdRunArguments( + request: NativeServiceProbeRequest, + unitName: string, + shellCommand: string, +): readonly string[] { + return [ + "--user", + "--wait", + "--collect", + "--pipe", + "--quiet", + `--unit=${unitName}`, + ...Object.entries(request.environment).map(([key, value]) => `--setenv=${key}=${value}`), + ...(request.workingDirectory === null ? [] : [`--working-directory=${request.workingDirectory}`]), + "/usr/bin/env", + request.shell.executable, + "-lc", + shellCommand, + ]; +} + +export function launchdProbePlist( + request: NativeServiceProbeRequest, + label: string, + shellCommand: string, + stdoutPath: string, + stderrPath: string, +): string { + const argumentsXml = ["/usr/bin/env", request.shell.executable, "-lc", shellCommand] + .map((argument) => ` ${xmlEscape(argument)}`) + .join("\n"); + const environmentEntries = Object.entries(request.environment); + const environmentXml = environmentEntries.length === 0 + ? "" + : ` EnvironmentVariables\n \n${environmentEntries.map(([key, value]) => plistString(key, value, " ")).join("")} \n`; + const workingDirectoryXml = request.workingDirectory === null + ? "" + : plistString("WorkingDirectory", request.workingDirectory); + return ` + + + +${plistString("Label", label)} ProgramArguments + +${argumentsXml} + +${workingDirectoryXml}${environmentXml} RunAtLoad + +${plistString("StandardOutPath", stdoutPath)}${plistString("StandardErrorPath", stderrPath)} + +`; +} + +class SpawnProbeCommandRunner implements ProbeCommandRunner { + public run(command: string, args: readonly string[], timeoutMs: number): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let spawnFailure: string | null = null; + let timedOut = false; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { stdout += chunk; }); + child.stderr.on("data", (chunk: string) => { stderr += chunk; }); + child.on("error", (error) => { spawnFailure = error.message; }); + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeoutMs); + child.on("close", (status) => { + clearTimeout(timeout); + if (timedOut) { + resolve({ kind: "timeout", stdout, stderr }); + } else if (spawnFailure !== null) { + resolve({ kind: "spawn-failure", message: spawnFailure, stdout, stderr }); + } else { + resolve({ kind: "completed", status: status ?? 1, stdout, stderr }); + } + }); + }); + } +} + +const nodeLaunchdProbeFileSystem: LaunchdProbeFileSystem = { + createTemporaryDirectory: (prefix) => mkdtemp(prefix), + writeFile: (path, contents) => writeFile(path, contents, "utf8"), + readFile: (path) => readFile(path, "utf8"), + removeDirectory: (path) => rm(path, { recursive: true, force: true }), +}; + +function prerequisiteProbeCommand( + shell: NativeServiceShellName, + prerequisites: readonly NativeServicePrerequisite[], + outputPrefix: string, +): string { + return prerequisites.map((prerequisite) => { + const check = prerequisiteCheck(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"); + return `${check} >/dev/null 2>&1 && ${satisfied} || ${unsatisfied}`; + }).join("; ") || ":"; +} + +function prerequisiteCheck(shell: NativeServiceShellName, prerequisite: NativeServicePrerequisite): string { + switch (prerequisite.kind) { + case "command-available": + return `command -v ${shellQuote(shell, prerequisite.command)}`; + case "node-version": { + const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`; + return `node -e ${shellQuote(shell, script)}`; + } + case "readable-file": + return `test -r ${shellQuote(shell, prerequisite.path)}`; + case "package-scripts": { + const script = "const p=require(process.argv[1]);const names=process.argv.slice(2);process.exit(names.every((name)=>typeof p.scripts?.[name]==='string')?0:1)"; + return ["node", "-e", shellQuote(shell, script), shellQuote(shell, prerequisite.packageJsonPath), ...prerequisite.scripts.map((name) => shellQuote(shell, name))].join(" "); + } + } +} + +function markerCommand( + shell: NativeServiceShellName, + outputPrefix: string, + encodedId: string, + status: "satisfied" | "unsatisfied", +): string { + return `printf '%s\\t%s\\t%s\\n' ${shellQuote(shell, outputPrefix)} ${shellQuote(shell, encodedId)} ${shellQuote(shell, status)}`; +} + +function parseProbeOutput( + stdout: string, + prerequisites: readonly NativeServicePrerequisite[], + outputPrefix: string, +): NativeServiceProbeResult { + const expected = new Map(prerequisites.map((prerequisite) => [ + Buffer.from(prerequisite.id, "utf8").toString("base64"), + prerequisite, + ])); + const outcomes = new Map(); + for (const line of stdout.split(/\r?\n/u)) { + if (!line.startsWith(`${outputPrefix}\t`)) continue; + const fields = line.split("\t"); + if (fields.length !== 3) { + return infrastructureFailure("malformed-output", "Authoritative probe returned a malformed result line."); + } + const encodedId = fields[1]; + const status = fields[2]; + const prerequisite = encodedId === undefined ? undefined : expected.get(encodedId); + if (prerequisite === undefined || (status !== "satisfied" && status !== "unsatisfied")) { + return infrastructureFailure("malformed-output", "Authoritative probe returned an unexpected result."); + } + if (outcomes.has(prerequisite.id)) { + return infrastructureFailure("malformed-output", `Authoritative probe returned duplicate outcome ${prerequisite.id}.`); + } + outcomes.set(prerequisite.id, { + prerequisiteId: prerequisite.id, + status, + detail: status === "satisfied" ? null : unsatisfiedDetail(prerequisite), + }); + } + const missing = prerequisites.find((prerequisite) => !outcomes.has(prerequisite.id)); + if (missing !== undefined) { + return infrastructureFailure("malformed-output", `Authoritative probe returned no outcome for ${missing.id}.`); + } + return { kind: "completed", outcomes: [...outcomes.values()] }; +} + +function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string { + switch (prerequisite.kind) { + case "command-available": + return `${prerequisite.command} was not found in the native service environment.`; + case "node-version": + return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`; + case "readable-file": + return `${prerequisite.path} was not readable in the native service environment.`; + case "package-scripts": + return `${prerequisite.packageJsonPath} did not provide scripts ${prerequisite.scripts.join(", ")} in the native service environment.`; + } +} + +function shellQuote(shell: NativeServiceShellName, value: string): string { + return shell === "fish" + ? `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'` + : `'${value.replaceAll("'", "'\\''")}'`; +} + +function safeUniqueId(value: string): string { + const safe = value.toLowerCase().replaceAll(/[^a-z0-9-]/gu, "").slice(0, 48); + return safe === "" ? "probe" : safe; +} + +function launchdField(output: string, field: string): string | undefined { + return new RegExp(`^\\s*${escapeRegExp(field)}\\s*=\\s*(.+)$`, "mu").exec(output)?.[1]?.trim(); +} + +function launchdIntegerField(output: string, field: string): number | undefined { + const value = launchdField(output, field); + if (value === undefined || !/^-?\d+$/u.test(value)) return undefined; + return Number(value); +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function plistString(key: string, value: string, indent = " "): string { + return `${indent}${xmlEscape(key)}\n${indent}${xmlEscape(value)}\n`; +} + +function xmlEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function commandInfrastructureFailure(action: string, result: ProbeCommandResult): NativeServiceProbeResult { + if (result.kind === "timeout") return infrastructureFailure("timeout", `Timed out while trying to ${action}.`); + return infrastructureFailure("manager", `Could not ${action}: ${commandFailureDetail(result)}`); +} + +function commandFailureDetail(result: ProbeCommandResult): string { + if (result.kind === "timeout") return "command timed out"; + if (result.kind === "spawn-failure") return result.message; + return firstOutput(result.stderr, result.stdout, `exit status ${String(result.status)}`); +} + +function infrastructureFailure( + reason: "manager" | "timeout" | "malformed-output" | "cleanup", + message: string, +): NativeServiceProbeResult { + return { kind: "infrastructure-failure", reason, message }; +} + +function firstOutput(...values: string[]): string { + for (const value of values) { + const line = value.trim().split(/\r?\n/u).find((candidate) => candidate.trim() !== ""); + if (line !== undefined) return line.trim(); + } + return "no output"; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/nativeServices/serviceRendering.test.ts b/src/nativeServices/serviceRendering.test.ts new file mode 100644 index 0000000..eea8868 --- /dev/null +++ b/src/nativeServices/serviceRendering.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + createDevelopmentNativeServicePlan, + type NativeServicePlan, + type NativeServicePlanService, +} from "./servicePlan.js"; +import { renderLaunchdPlist, renderSystemdUnit } from "./serviceRendering.js"; + +function developmentPlan(kind: "systemd" | "launchd"): NativeServicePlan { + return createDevelopmentNativeServicePlan({ + backend: { kind, label: kind }, + shell: { + name: "zsh", + executable: "/bin/zsh", + source: "detected", + detectedExecutable: "/bin/zsh", + }, + environment: { PI_WEB_CONFIG: "/home/user/config with \"quote\".json" }, + workingDirectory: "/checkout with space", + packageJsonPath: "/checkout with space/package.json", + }); +} + +function planService(plan: NativeServicePlan, index: number): NativeServicePlanService { + const service = plan.services[index]; + if (service === undefined) throw new Error(`Missing service at index ${String(index)}`); + return service; +} + +describe("native service rendering", () => { + it("renders systemd entirely from the canonical plan", () => { + const plan = developmentPlan("systemd"); + const unit = renderSystemdUnit(plan, planService(plan, 1)); + + expect(unit).toContain("Description=PI WEB UI dev server"); + expect(unit).toContain("After=pi-web-sessiond.service\nWants=pi-web-sessiond.service"); + expect(unit).toContain('WorkingDirectory="/checkout with space"'); + expect(unit).toContain('Environment="PI_WEB_CONFIG=/home/user/config with \\"quote\\".json"'); + expect(unit).toContain("ExecStart=/usr/bin/env /bin/zsh -lc 'exec /usr/bin/env bash -c '\\''trap"); + expect(unit).toContain("Restart=no"); + }); + + it("renders launchd entirely from the canonical plan", () => { + const plan = developmentPlan("launchd"); + const plist = renderLaunchdPlist(plan, planService(plan, 0), "/logs"); + + expect(plist).toContain("com.pi-web.sessiond"); + expect(plist).toContain("/bin/zsh"); + expect(plist).toContain("exec npm run start:sessiond"); + expect(plist).toContain("WorkingDirectory\n /checkout with space"); + expect(plist).toContain("PI_WEB_CONFIG\n /home/user/config with "quote".json"); + expect(plist).toContain("/logs/sessiond.log"); + expect(plist).not.toContain("KeepAlive"); + }); + + it("rejects a service from a different plan", () => { + const first = developmentPlan("systemd"); + const second = developmentPlan("systemd"); + expect(() => renderSystemdUnit(first, planService(second, 0))).toThrow("not a member"); + }); +}); diff --git a/src/nativeServices/serviceRendering.ts b/src/nativeServices/serviceRendering.ts new file mode 100644 index 0000000..39e1d4c --- /dev/null +++ b/src/nativeServices/serviceRendering.ts @@ -0,0 +1,129 @@ +import { join } from "node:path"; +import type { + NativeServiceId, + NativeServicePlan, + NativeServicePlanService, + NativeServiceShellName, +} from "./servicePlan.js"; + +export function renderSystemdUnit( + plan: NativeServicePlan, + service: NativeServicePlanService, +): string { + assertPlanService(plan, service); + assertBackend(plan, "systemd"); + const workingDirectory = service.workingDirectory === null + ? "" + : `WorkingDirectory=${systemdQuotedValue(service.workingDirectory)}\n`; + const restart = service.restart === "on-failure" + ? "Restart=on-failure\nRestartSec=2\n" + : "Restart=no\n"; + return `[Unit] +Description=${service.description} +${systemdDependencyLine(plan, "After", service.after)}${systemdDependencyLine(plan, "Wants", service.wants)}[Service] +Type=simple +${workingDirectory}${systemdEnvironmentLines(service.environment)}ExecStart=/usr/bin/env ${plan.shell.executable} -lc ${systemdServiceShellQuote(plan.shell.name, service.shellCommand)} +${restart} +[Install] +WantedBy=default.target +`; +} + +export function renderLaunchdPlist( + plan: NativeServicePlan, + service: NativeServicePlanService, + logDirectory: string, +): string { + assertPlanService(plan, service); + assertBackend(plan, "launchd"); + const programArguments = ["/usr/bin/env", plan.shell.executable, "-lc", service.shellCommand]; + const workingDirectory = service.workingDirectory === null + ? "" + : plistString("WorkingDirectory", service.workingDirectory); + const keepAlive = service.restart === "on-failure" + ? " KeepAlive\n \n SuccessfulExit\n \n \n" + : ""; + const logPath = join(logDirectory, service.manager.logName); + return ` + + + +${plistString("Label", service.manager.launchdLabel)}${plistProgramArguments(programArguments)}${workingDirectory}${plistEnvironment(service.environment)} RunAtLoad + +${keepAlive}${plistString("StandardOutPath", logPath)}${plistString("StandardErrorPath", logPath)} + +`; +} + +function assertPlanService(plan: NativeServicePlan, service: NativeServicePlanService): void { + if (!plan.services.includes(service)) { + throw new Error(`Cannot render ${service.id}; it is not a member of the supplied native service plan.`); + } +} + +function assertBackend(plan: NativeServicePlan, expected: "systemd" | "launchd"): void { + if (plan.backend.kind !== expected) { + throw new Error(`Cannot render ${expected} service from a ${plan.backend.kind} native service plan.`); + } +} + +function systemdDependencyLine( + plan: NativeServicePlan, + name: "After" | "Wants", + ids: readonly NativeServiceId[], +): string { + if (ids.length === 0) return ""; + const names = ids.map((id) => { + const dependency = plan.services.find((service) => service.id === id); + if (dependency === undefined) throw new Error(`Service ${id} is not present in the native service plan.`); + return dependency.manager.systemdName; + }); + return `${name}=${names.join(" ")}\n`; +} + +function systemdEnvironmentLines(environment: Readonly>): string { + return Object.entries(environment) + .map(([key, value]) => `Environment="${systemdEscape(key)}=${systemdEscape(value)}"\n`) + .join(""); +} + +function systemdServiceShellQuote(shell: NativeServiceShellName, value: string): string { + return shellQuote(shell, value.replaceAll("%", "%%").replaceAll("$", "$$")); +} + +function systemdQuotedValue(value: string): string { + return `"${systemdEscape(value)}"`; +} + +function systemdEscape(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +function plistProgramArguments(arguments_: readonly string[]): string { + return ` ProgramArguments\n \n${arguments_.map((argument) => ` ${xmlEscape(argument)}`).join("\n")}\n \n`; +} + +function plistEnvironment(environment: Readonly>): string { + const entries = Object.entries(environment); + if (entries.length === 0) return ""; + return ` EnvironmentVariables\n \n${entries.map(([key, value]) => plistString(key, value, " ")).join("")} \n`; +} + +function plistString(key: string, value: string, indent = " "): string { + return `${indent}${xmlEscape(key)}\n${indent}${xmlEscape(value)}\n`; +} + +function shellQuote(shell: NativeServiceShellName, value: string): string { + return shell === "fish" + ? `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'` + : `'${value.replaceAll("'", "'\\''")}'`; +} + +function xmlEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} From dde48b3b11b5200f3e4be371e5ceb92225931c63 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 00:41:51 +0200 Subject: [PATCH 06/12] fix(cli): diagnose native service plans in manager context --- .changeset/doctor-native-service-context.md | 5 + README.md | 2 + docs/faq.html | 20 +- docs/index.html | 4 +- docs/install.html | 7 +- src/cli.test.ts | 31 +- src/cli.ts | 357 +++++++++------- src/nativeServices/serviceDoctor.test.ts | 238 +++++++++++ src/nativeServices/serviceDoctor.ts | 424 ++++++++++++++++++++ src/nativeServices/serviceProbe.test.ts | 93 +++++ src/nativeServices/serviceProbe.ts | 4 +- 11 files changed, 1021 insertions(+), 164 deletions(-) create mode 100644 .changeset/doctor-native-service-context.md create mode 100644 src/nativeServices/serviceDoctor.test.ts create mode 100644 src/nativeServices/serviceDoctor.ts diff --git a/.changeset/doctor-native-service-context.md b/.changeset/doctor-native-service-context.md new file mode 100644 index 0000000..0bd0b13 --- /dev/null +++ b/.changeset/doctor-native-service-context.md @@ -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. diff --git a/README.md b/README.md index fbbc619..a759c2d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/faq.html b/docs/faq.html index 6e30e6d..b0f0cfa 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -131,8 +131,9 @@

Tools are failing, node is not found, or Pi cannot find commands

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.

@@ -156,14 +157,17 @@

What does pi-web doctor check?

- 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 @-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 gui/<uid> context. Development installs follow their + installed checkout plan; production checks are clearly labelled prospective when the installed executable + strategy cannot be reconstructed safely.

- 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.

diff --git a/docs/index.html b/docs/index.html index d1a1d31..56afff4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -138,8 +138,8 @@ # laptop, phone, tablet — same live sessions $ 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
diff --git a/docs/install.html b/docs/install.html index 9f20d7f..0c9bced 100644 --- a/docs/install.html +++ b/docs/install.html @@ -112,8 +112,10 @@
Important PATH detail: - PI WEB services run through your login shell with -lc. Setup that only lives in interactive shell - files or prompt hooks may not be visible to services. Run pi-web doctor after installing. + PI WEB services run through a non-interactive login shell with -lc. 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 + pi-web doctor later to repeat plan-specific diagnostics.
@@ -134,6 +136,7 @@ $ pi-web doctor

Then open http://127.0.0.1:8504.

+

If preflight fails, no config or existing services are changed. Follow the detected shell guidance: zsh services read ~/.zprofile, not interactive-only ~/.zshrc; bash uses ~/.bash_profile or ~/.profile.

On Linux servers, also consider sudo loginctl enable-linger "$USER" so user services survive logout/reboot.

diff --git a/src/cli.test.ts b/src/cli.test.ts index 72da8f8..2131b10 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -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); diff --git a/src/cli.ts b/src/cli.ts index 6783394..5e395d5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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>, +): 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 { + 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(); + 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 { 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 { 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 { diff --git a/src/nativeServices/serviceDoctor.test.ts b/src/nativeServices/serviceDoctor.test.ts new file mode 100644 index 0000000..5f802bd --- /dev/null +++ b/src/nativeServices/serviceDoctor.test.ts @@ -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"); + }); +}); diff --git a/src/nativeServices/serviceDoctor.ts b/src/nativeServices/serviceDoctor.ts new file mode 100644 index 0000000..9739dd6 --- /dev/null +++ b/src/nativeServices/serviceDoctor.ts @@ -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>; +} + +export type InstalledNativeServiceInspection = + | { 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>; + workingDirectory: string | null; + shellCommand: string; +} + +export function inferInstalledNativeServiceMode(serviceIds: ReadonlySet): 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 { + 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 { + 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 { + 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 { + 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 { + 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 = {}; + 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 { + const argumentsBlock = /ProgramArguments<\/key>\s*([\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(/([\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 = {}; + const environmentBlock = /EnvironmentVariables<\/key>\s*([\s\S]*?)<\/dict>/u.exec(definition.contents)?.[1]; + if (environmentBlock !== undefined) { + for (const match of environmentBlock.matchAll(/([\s\S]*?)<\/key>\s*([\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 { + 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(`${escapedKey}<\\/key>\\s*([\\s\\S]*?)<\\/string>`, "u").exec(contents)?.[1]; + return value === undefined ? null : xmlUnescape(value); +} + +function xmlUnescape(value: string): string { + return value + .replaceAll("'", "'") + .replaceAll(""", '"') + .replaceAll(">", ">") + .replaceAll("<", "<") + .replaceAll("&", "&"); +} + +function recordsEqual(left: Readonly>, right: Readonly>): 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"); +} diff --git a/src/nativeServices/serviceProbe.test.ts b/src/nativeServices/serviceProbe.test.ts index 122e4bb..657cb64 100644 --- a/src/nativeServices/serviceProbe.test.ts +++ b/src/nativeServices/serviceProbe.test.ts @@ -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", () => { diff --git a/src/nativeServices/serviceProbe.ts b/src/nativeServices/serviceProbe.ts index 3f47624..97cd1bc 100644 --- a/src/nativeServices/serviceProbe.ts +++ b/src/nativeServices/serviceProbe.ts @@ -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)}`; From 767e653028029cfda41c9cb5362807f9ea5df335 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 01:20:15 +0200 Subject: [PATCH 07/12] fix(cli): harden native service manager preflight --- .changeset/doctor-native-service-context.md | 2 +- README.md | 2 +- docs/install.html | 5 +- src/cli.test.ts | 14 + src/cli.ts | 22 +- src/nativeServices/serviceDoctor.test.ts | 145 +++++++++- src/nativeServices/serviceDoctor.ts | 265 ++++++++++++++---- src/nativeServices/serviceInstall.test.ts | 43 ++- src/nativeServices/serviceInstall.ts | 11 + src/nativeServices/servicePlan.test.ts | 9 +- src/nativeServices/servicePlan.ts | 10 +- src/nativeServices/serviceProbe.test.ts | 190 +++++++++---- src/nativeServices/serviceProbe.ts | 292 ++++++++++++-------- src/nativeServices/serviceRendering.test.ts | 25 +- src/nativeServices/serviceRendering.ts | 42 +-- 15 files changed, 829 insertions(+), 248 deletions(-) diff --git a/.changeset/doctor-native-service-context.md b/.changeset/doctor-native-service-context.md index 0bd0b13..8969167 100644 --- a/.changeset/doctor-native-service-context.md +++ b/.changeset/doctor-native-service-context.md @@ -2,4 +2,4 @@ "@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. +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 root-cause analysis. diff --git a/README.md b/README.md index a759c2d..5547cea 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ 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. +`pi-web install` validates the safely verifiable requirements of the exact production or development service plan inside the native user-service manager before changing config or replacing services; arbitrary configured command overrides are preserved but not executed by preflight. `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). diff --git a/docs/install.html b/docs/install.html index 0c9bced..01e98e0 100644 --- a/docs/install.html +++ b/docs/install.html @@ -114,8 +114,9 @@ Important PATH detail: PI WEB services run through a non-interactive login shell with -lc. 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 - pi-web doctor later to repeat plan-specific diagnostics. + probes the safely verifiable requirements of the exact candidate plan in that manager context before changing + config or replacing services. Arbitrary configured command overrides are preserved but not executed by + preflight; run pi-web doctor later to repeat plan-specific diagnostics.
diff --git a/src/cli.test.ts b/src/cli.test.ts index 2131b10..1eb6aae 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -7,6 +7,7 @@ import { doctorExitCode, isCliEntrypoint, launchdRuntimeDetails, + regularFileExists, serviceBackendForPlatform, } from "./cli.js"; @@ -53,6 +54,19 @@ describe("native-service doctor CLI contracts", () => { expect(doctorExitCode(true, true, false)).toBe(1); }); + it("accepts only regular files as bundled entrypoints", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-web-entrypoint-test-")); + try { + const file = join(dir, "entrypoint.js"); + writeFileSync(file, "export {};\n"); + expect(regularFileExists(file)).toBe(true); + expect(regularFileExists(dir)).toBe(false); + expect(regularFileExists(join(dir, "missing.js"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("surfaces launchd last exit code 127 in service status", () => { expect(launchdRuntimeDetails("state = exited\nlast exit code = 127\n")).toEqual({ state: "exited", diff --git a/src/cli.ts b/src/cli.ts index 5e395d5..035cd19 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir, userInfo } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; @@ -10,6 +10,7 @@ import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; import { installNativeServiceCandidate, + nativeServiceInstallFailureNeedsPathAdvice, type NativeServiceInstallCandidate, type NativeServiceInstallFailure, } from "./nativeServices/serviceInstall.js"; @@ -218,6 +219,10 @@ function packageEntrypointPath(name: "server" | "sessiond"): string { return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js"); } +export function regularFileExists(path: string): boolean { + return existsSync(path) && statSync(path).isFile(); +} + function detectServiceShell(): NativeServiceShell { const userShell = userInfo().shell ?? undefined; const envShell = process.env["SHELL"]?.trim(); @@ -660,15 +665,18 @@ async function install(args: string[]): Promise { console.log(`Service shell: ${describeServiceShell()}`); const result = await installNativeServiceCandidate(candidate, { probe: createNativeServiceAuthoritativeProbe(), - fileExists: existsSync, + fileExists: regularFileExists, writeInitialConfig: () => writeInitialConfig(options, configPath), replaceServices: installNativeServices, }); if (!result.ok) { printNativeServiceInstallFailure(result.failure); - printPathSetupAdvice(); + if (nativeServiceInstallFailureNeedsPathAdvice(result.failure)) printPathSetupAdvice(); throw new Error("Install preflight checks failed without changing config or services. Fix the failure above, then run `pi-web doctor` for more detail."); } + for (const service of result.plan.services.filter((item) => item.strategy.kind === "configured-override")) { + console.log(`! ${service.description} uses a configured command override; preflight did not execute that arbitrary command.`); + } console.log(`\nPI WEB ${options.mode} services are installed and starting.`); console.log(`Config: ${configPath}`); @@ -893,7 +901,7 @@ function nativeServiceDoctorTarget(backend: ServiceBackend): NativeServiceDoctor async function printNativeServiceDoctorChecks(backend: ServiceBackend): Promise { const result = await runNativeServiceDoctor(nativeServiceDoctorTarget(backend), { probe: createNativeServiceAuthoritativeProbe(), - fileExists: existsSync, + fileExists: regularFileExists, }); const report = formatNativeServiceDoctorResult(result); for (const line of report.lines) console.log(line); @@ -994,11 +1002,11 @@ async function doctor(): Promise { } const nativeServicePlanOk = nativeServiceReport?.ok ?? true; - const pathFailure = !generalReadinessOk || nativeServiceReport?.failureKind === "requirements"; + const pathFailure = !generalReadinessOk || nativeServiceReport?.pathAdviceRecommended === true; 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 + const adviceShell = nativeServiceReport?.pathAdviceRecommended === true && nativeServiceReport.adviceShell !== null + ? nativeServiceReport.adviceShell : detectServiceShell(); printPathSetupAdvice(adviceShell); } diff --git a/src/nativeServices/serviceDoctor.test.ts b/src/nativeServices/serviceDoctor.test.ts index 5f802bd..3da5e8d 100644 --- a/src/nativeServices/serviceDoctor.test.ts +++ b/src/nativeServices/serviceDoctor.test.ts @@ -98,13 +98,39 @@ describe("installed native-service mode and definition inspection", () => { }); }); + it("reconstructs escaped systemd paths, substitutions, and line controls exactly", () => { + const plan = createDevelopmentNativeServicePlan({ + backend: { kind: "systemd", label: "systemd" }, + shell: { + name: "zsh", + executable: "/shell $HOME/%h/zsh", + source: "detected", + detectedExecutable: "/shell $HOME/%h/zsh", + }, + environment: { PI_WEB_CONFIG: "/config/%h\nnext" }, + workingDirectory: "/checkout %h\nnext", + packageJsonPath: "/checkout %h\nnext/package.json", + }); + + expect(inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan))).toEqual({ + ok: true, + value: { + backend: plan.backend, + shell: plan.shell, + environment: plan.services[0]?.environment, + workingDirectory: "/checkout %h\nnext", + packageJsonPath: "/checkout %h\nnext/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"), + .replace("WorkingDirectory=/checkout\\x20with\\x20space", "WorkingDirectory=/checkout with space"), })); expect(inspectInstalledDevelopmentServiceInput(plan.backend, definitions)).toMatchObject({ @@ -113,6 +139,80 @@ describe("installed native-service mode and definition inspection", () => { }); }); + it("rejects quoted systemd working directories that the manager treats as non-absolute", () => { + const plan = developmentPlan("systemd"); + const definitions = renderedDefinitions(plan).map((definition) => ({ + ...definition, + contents: definition.contents.replace( + "WorkingDirectory=/checkout\\x20with\\x20space", + 'WorkingDirectory="/checkout with space"', + ), + })); + + const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions); + expect(inspection.ok).toBe(false); + if (inspection.ok) throw new Error("Expected quoted working directory inspection to fail"); + expect(inspection.message).toContain("invalid quoted working directory"); + }); + + it("rejects unconsumed systemd environment syntax rather than checking a different context", () => { + const plan = developmentPlan("systemd"); + const definitions = renderedDefinitions(plan).map((definition) => ({ + ...definition, + contents: definition.contents.replace("[Service]\n", "[Service]\nEnvironment=PATH=/custom/bin\n"), + })); + + const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions); + expect(inspection.ok).toBe(false); + if (inspection.ok) throw new Error("Expected systemd environment inspection to fail"); + expect(inspection.message).toContain("environment entry"); + }); + + it.each([ + 'Environment="PI_WEB_CONFIG=/config" "PATH=/broken"', + "EnvironmentFile=/tmp/pi-web.env", + ])("rejects noncanonical systemd environment context: %s", (directive) => { + const plan = developmentPlan("systemd"); + const definitions = renderedDefinitions(plan).map((definition) => ({ + ...definition, + contents: definition.contents.replace("[Service]\n", `[Service]\n${directive}\n`), + })); + + expect(inspectInstalledDevelopmentServiceInput(plan.backend, definitions).ok).toBe(false); + }); + + it("rejects duplicate systemd ExecStart directives", () => { + const plan = developmentPlan("systemd"); + const definitions = renderedDefinitions(plan).map((definition) => ({ + ...definition, + contents: definition.contents.replace( + "Restart=no", + 'ExecStart=/usr/bin/env "/bin/zsh" -lc "exec true"\nRestart=no', + ), + })); + + const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions); + expect(inspection.ok).toBe(false); + if (inspection.ok) throw new Error("Expected duplicate ExecStart inspection to fail"); + expect(inspection.message).toContain("exactly one recognized ExecStart"); + }); + + it("rejects malformed launchd environment dictionaries rather than dropping entries", () => { + const plan = developmentPlan("launchd"); + const definitions = renderedDefinitions(plan).map((definition) => ({ + ...definition, + contents: definition.contents.replace( + " \n RunAtLoad", + " BROKEN\n 1\n \n RunAtLoad", + ), + })); + + const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions); + expect(inspection.ok).toBe(false); + if (inspection.ok) throw new Error("Expected launchd environment inspection to fail"); + expect(inspection.message).toContain("environment dictionary"); + }); + it("rejects a modified development command rather than claiming to check the installed plan", () => { const plan = developmentPlan("systemd"); const definitions = renderedDefinitions(plan); @@ -168,6 +268,31 @@ describe("native-service doctor planning and reporting", () => { ); }); + it("does not recommend PATH changes for checkout metadata failures", async () => { + const plan = developmentPlan("systemd"); + 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: { + run: (request) => Promise.resolve({ + kind: "completed", + outcomes: request.prerequisites.map((prerequisite) => ({ + prerequisiteId: prerequisite.id, + status: prerequisite.kind === "package-scripts" ? "unsatisfied" as const : "satisfied" as const, + detail: prerequisite.kind === "package-scripts" ? "scripts missing" : null, + })), + }), + }, + fileExists: () => true, + }, + ); + const report = formatNativeServiceDoctorResult(result); + + expect(report).toMatchObject({ ok: false, failureKind: "requirements", pathAdviceRecommended: false }); + }); + it("labels a production check as prospective and reports manager-context requirements", async () => { const target: NativeServiceDoctorTarget = { kind: "prospective-production", @@ -190,6 +315,22 @@ describe("native-service doctor planning and reporting", () => { ])); }); + it("retains the installed production shell when resolution fails before a plan exists", async () => { + const result = await runNativeServiceDoctor( + { kind: "prospective-production", input: productionInput(), reason: "installed strategy is unknown" }, + { probe: probeWithStatus("unsatisfied"), fileExists: () => false }, + ); + const report = formatNativeServiceDoctorResult(result); + + expect(report).toMatchObject({ + ok: false, + failureKind: "requirements", + plan: null, + adviceShell: shell, + pathAdviceRecommended: true, + }); + }); + it("preserves configured overrides as unverified and does not probe arbitrary commands", async () => { let calls = 0; const result = await runNativeServiceDoctor( @@ -201,7 +342,7 @@ describe("native-service doctor planning and reporting", () => { ); const report = formatNativeServiceDoctorResult(result); - expect(calls).toBe(0); + expect(calls).toBe(1); expect(report.ok).toBe(true); expect(report.lines.join("\n")).toContain("does not execute arbitrary configured commands"); }); diff --git a/src/nativeServices/serviceDoctor.ts b/src/nativeServices/serviceDoctor.ts index 9739dd6..9bc0a1e 100644 --- a/src/nativeServices/serviceDoctor.ts +++ b/src/nativeServices/serviceDoctor.ts @@ -1,6 +1,7 @@ import { basename, join } from "node:path"; import { createDevelopmentNativeServicePlan, + nativeServicePrerequisiteNeedsPathAdvice, resolveProductionNativeServicePlan, validateNativeServicePlan, type DevelopmentNativeServicePlanInput, @@ -49,6 +50,7 @@ export type NativeServiceDoctorTarget = interface NativeServiceDoctorScope { kind: "installed-development" | "prospective-production"; reason: string | null; + shell: NativeServiceShell; } export type NativeServiceDoctorResult = @@ -73,6 +75,8 @@ export interface NativeServiceDoctorReport { failureKind: "none" | "requirements" | "infrastructure" | "inspection"; lines: readonly string[]; plan: NativeServicePlan | null; + adviceShell: NativeServiceShell | null; + pathAdviceRecommended: boolean; failedPrerequisites: readonly NativeServicePrerequisite[]; } @@ -153,8 +157,8 @@ export async function runNativeServiceDoctor( 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 }; + ? { kind: target.kind, reason: null, shell: target.input.shell } + : { kind: target.kind, reason: target.reason, shell: target.input.shell }; let plan: NativeServicePlan; if (target.kind === "installed-development") { plan = createDevelopmentNativeServicePlan(target.input); @@ -180,6 +184,8 @@ export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResul " Run `pi-web install` or `pi-web install --dev` to replace mixed, partial, or outdated service definitions.", ], plan: null, + adviceShell: null, + pathAdviceRecommended: false, failedPrerequisites: [], }; } @@ -205,6 +211,9 @@ export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResul failureKind: infrastructure ? "infrastructure" : "requirements", lines, plan: null, + adviceShell: result.scope.shell, + pathAdviceRecommended: !infrastructure + && result.failures.some((failure) => failure.kind === "executable-unavailable"), failedPrerequisites: [], }; } @@ -215,7 +224,15 @@ export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResul } 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: [] }; + return { + ok: true, + failureKind: "none", + lines, + plan: result.plan, + adviceShell: result.plan.shell, + pathAdviceRecommended: false, + failedPrerequisites: [], + }; } const failedPrerequisites: NativeServicePrerequisite[] = []; @@ -236,6 +253,9 @@ export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResul failureKind: infrastructure ? "infrastructure" : "requirements", lines, plan: result.plan, + adviceShell: result.plan.shell, + pathAdviceRecommended: !infrastructure + && failedPrerequisites.some(nativeServicePrerequisiteNeedsPathAdvice), failedPrerequisites, }; } @@ -275,33 +295,85 @@ function parseConsistentDefinitions( return { ok: true, value: parsed }; } +interface ParsedSystemdDirective { + name: string; + value: string; +} + +function systemdServiceDirectives(contents: string): ParsedSystemdDirective[] | undefined { + const allowed = new Set(["Type", "WorkingDirectory", "Environment", "ExecStart", "Restart", "RestartSec"]); + const directives: ParsedSystemdDirective[] = []; + let inServiceSection = false; + let foundServiceSection = false; + for (const line of contents.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (/^\[[^\]]+\]$/u.test(trimmed)) { + inServiceSection = trimmed === "[Service]"; + foundServiceSection ||= inServiceSection; + continue; + } + if (!inServiceSection || trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith(";")) continue; + const match = /^\s*([A-Za-z][A-Za-z0-9]*)=(.*)$/u.exec(line); + const name = match?.[1]; + const value = match?.[2]; + if (name === undefined || value === undefined || !allowed.has(name)) return undefined; + directives.push({ name, value }); + } + return foundServiceSection ? directives : undefined; +} + function parseSystemdDefinition( definition: InstalledNativeServiceDefinition, ): InstalledNativeServiceInspection { - 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 directives = systemdServiceDirectives(definition.contents); + if (directives === undefined) { + return { ok: false, message: `Installed ${definition.id} systemd unit has unrecognized service directives.` }; } - const shell = installedShell(execStart[1]); + const execStarts = directives.filter((directive) => directive.name === "ExecStart"); + const execStart = execStarts.length === 1 + ? /^(?:\/usr\/bin\/env )?(.+?) -lc (.+)$/u.exec(execStarts[0]?.value ?? "") + : null; + if (execStart?.[1] === undefined || execStart[2] === undefined) { + return { ok: false, message: `Installed ${definition.id} systemd unit must have exactly one recognized ExecStart.` }; + } + const shellExecutable = parseSystemdExecArgument(execStart[1]); + if (shellExecutable === undefined) { + return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized login shell argument.` }; + } + const shell = installedShell(shellExecutable); if (!shell.ok) return shell; - const shellCommand = parseShellQuotedValue(shell.value.name, execStart[2]); + const shellCommand = parseSystemdShellCommand(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 = {}; - 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); + for (const directive of directives.filter((item) => item.name === "Environment")) { + const rawValue = directive.value; + if (!/^"(?:\\.|[^"])*"$/u.test(rawValue)) { + return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized environment entry.` }; + } + const assignment = parseSystemdDirectiveValue(rawValue); + const separator = assignment?.indexOf("=") ?? -1; + const key = assignment?.slice(0, separator) ?? ""; + if (separator <= 0 || Object.hasOwn(environment, key)) { + return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed environment entry.` }; + } + environment[key] = assignment?.slice(separator + 1) ?? ""; } - const workingDirectoryMatch = /^WorkingDirectory=(.+)$/mu.exec(definition.contents); - const workingDirectory = workingDirectoryMatch?.[1] === undefined + const workingDirectories = directives.filter((directive) => directive.name === "WorkingDirectory"); + if (workingDirectories.length > 1) { + return { ok: false, message: `Installed ${definition.id} systemd unit has duplicate working directories.` }; + } + const rawWorkingDirectory = workingDirectories[0]?.value; + if (rawWorkingDirectory?.startsWith('"') === true || rawWorkingDirectory?.startsWith("'") === true) { + return { ok: false, message: `Installed ${definition.id} systemd unit has an invalid quoted working directory.` }; + } + const workingDirectory = rawWorkingDirectory === undefined ? null - : parseSystemdValue(workingDirectoryMatch[1]); - if (workingDirectoryMatch !== null && workingDirectory === undefined) { + : parseSystemdDirectiveValue(rawWorkingDirectory); + if (workingDirectories.length === 1 && workingDirectory === undefined) { return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed working directory.` }; } @@ -314,25 +386,42 @@ function parseSystemdDefinition( function parseLaunchdDefinition( definition: InstalledNativeServiceDefinition, ): InstalledNativeServiceInspection { - const argumentsBlock = /ProgramArguments<\/key>\s*([\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(/([\s\S]*?)<\/string>/gu)].map((match) => xmlUnescape(match[1] ?? "")); - if (arguments_.length !== 4 || arguments_[0] !== "/usr/bin/env" || arguments_[2] !== "-lc") { + const argumentsMatches = [...definition.contents.matchAll(/ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/gu)]; + const arguments_ = argumentsMatches.length === 1 + ? parseXmlStringSequence(argumentsMatches[0]?.[1] ?? "") + : undefined; + 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 = {}; - const environmentBlock = /EnvironmentVariables<\/key>\s*([\s\S]*?)<\/dict>/u.exec(definition.contents)?.[1]; - if (environmentBlock !== undefined) { - for (const match of environmentBlock.matchAll(/([\s\S]*?)<\/key>\s*([\s\S]*?)<\/string>/gu)) { - environment[xmlUnescape(match[1] ?? "")] = xmlUnescape(match[2] ?? ""); - } + const environmentMatches = [...definition.contents.matchAll(/EnvironmentVariables<\/key>\s*([\s\S]*?)<\/dict>/gu)]; + const environmentKeyCount = [...definition.contents.matchAll(/EnvironmentVariables<\/key>/gu)].length; + if (environmentMatches.length > 1 || environmentKeyCount !== environmentMatches.length) { + return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed environment dictionary.` }; + } + const environment = environmentMatches.length === 0 + ? {} + : parseXmlStringDictionary(environmentMatches[0]?.[1] ?? ""); + if (environment === undefined) { + return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed environment dictionary.` }; + } + + const contentsWithoutEnvironment = environmentMatches[0]?.[0] === undefined + ? definition.contents + : definition.contents.replace(environmentMatches[0][0], ""); + const workingDirectoryMatches = [...contentsWithoutEnvironment.matchAll(/WorkingDirectory<\/key>\s*([\s\S]*?)<\/string>/gu)]; + const workingDirectoryKeyCount = [...contentsWithoutEnvironment.matchAll(/WorkingDirectory<\/key>/gu)].length; + if (workingDirectoryMatches.length > 1 || workingDirectoryKeyCount !== workingDirectoryMatches.length) { + return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed working directory.` }; + } + const workingDirectory = workingDirectoryMatches[0]?.[1] === undefined + ? null + : xmlUnescapeStrict(workingDirectoryMatches[0][1]); + if (workingDirectoryMatches.length === 1 && workingDirectory === undefined) { + return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed working directory.` }; } - const workingDirectory = launchdString(definition.contents, "WorkingDirectory"); return { ok: true, @@ -340,7 +429,7 @@ function parseLaunchdDefinition( id: definition.id, shell: shell.value, environment, - workingDirectory, + workingDirectory: workingDirectory ?? null, shellCommand: arguments_[3] ?? "", }, }; @@ -357,31 +446,87 @@ function installedShell(executable: string): InstalledNativeServiceInspection> = { + "\\": "\\", + '"': '"', + "'": "'", + a: "\u0007", + b: "\b", + e: "\u001b", + f: "\f", + n: "\n", + r: "\r", + s: " ", + t: "\t", + v: "\v", + }; + const simple = simpleEscapes[escape]; + if (simple !== undefined) { + result += simple; + index += 1; + continue; + } + + const length = escape === "x" ? 2 : escape === "u" ? 4 : escape === "U" ? 8 : 0; + if (length === 0) return undefined; + const encoded = value.slice(index + 2, index + 2 + length); + if (encoded.length !== length || !new RegExp(`^[0-9a-fA-F]{${String(length)}}$`, "u").test(encoded)) return undefined; + const codePoint = Number.parseInt(encoded, 16); + if (codePoint === 0 || codePoint > 0x10ffff) return undefined; + result += String.fromCodePoint(codePoint); + index += length + 1; } return result; } -function parseShellQuotedValue(shell: NativeServiceShell["name"], value: string): string | undefined { +function decodeSystemdSubstitutions(value: string, decodeDollars: boolean): string | undefined { + let result = ""; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character !== "%" && !(decodeDollars && character === "$")) { + result += character ?? ""; + continue; + } + if (value[index + 1] !== character) return undefined; + result += character; + index += 1; + } + return result; +} + +function parseSystemdShellCommand(shell: NativeServiceShell["name"], value: string): string | undefined { + if (value.startsWith('"') || value.endsWith('"')) return parseSystemdExecArgument(value); if (!value.startsWith("'") || !value.endsWith("'")) return undefined; const inner = value.slice(1, -1); - if (shell === "fish") return fishSingleQuoteUnescape(inner); - return inner.replaceAll("'\\''", "'").replaceAll("$$", "$").replaceAll("%%", "%"); + const unquoted = shell === "fish" ? fishSingleQuoteUnescape(inner) : inner.replaceAll("'\\''", "'"); + return decodeSystemdSubstitutions(unquoted, true); } function fishSingleQuoteUnescape(value: string): string { @@ -395,16 +540,38 @@ function fishSingleQuoteUnescape(value: string): string { result += character ?? ""; } } - return result.replaceAll("$$", "$").replaceAll("%%", "%"); + return result; } -function launchdString(contents: string, key: string): string | null { - const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&"); - const value = new RegExp(`${escapedKey}<\\/key>\\s*([\\s\\S]*?)<\\/string>`, "u").exec(contents)?.[1]; - return value === undefined ? null : xmlUnescape(value); +function parseXmlStringSequence(contents: string): string[] | undefined { + const values: string[] = []; + let cursor = 0; + for (const match of contents.matchAll(/([\s\S]*?)<\/string>/gu)) { + if (contents.slice(cursor, match.index).trim() !== "") return undefined; + const value = xmlUnescapeStrict(match[1] ?? ""); + if (value === undefined) return undefined; + values.push(value); + cursor = match.index + match[0].length; + } + return contents.slice(cursor).trim() === "" ? values : undefined; } -function xmlUnescape(value: string): string { +function parseXmlStringDictionary(contents: string): Record | undefined { + const values: Record = {}; + let cursor = 0; + for (const match of contents.matchAll(/([\s\S]*?)<\/key>\s*([\s\S]*?)<\/string>/gu)) { + if (contents.slice(cursor, match.index).trim() !== "") return undefined; + const key = xmlUnescapeStrict(match[1] ?? ""); + const value = xmlUnescapeStrict(match[2] ?? ""); + if (key === undefined || value === undefined || Object.hasOwn(values, key)) return undefined; + values[key] = value; + cursor = match.index + match[0].length; + } + return contents.slice(cursor).trim() === "" ? values : undefined; +} + +function xmlUnescapeStrict(value: string): string | undefined { + if (/[<>]/u.test(value) || /&(?!(?:apos|quot|gt|lt|amp);)/u.test(value)) return undefined; return value .replaceAll("'", "'") .replaceAll(""", '"') diff --git a/src/nativeServices/serviceInstall.test.ts b/src/nativeServices/serviceInstall.test.ts index 116a9fb..4a16033 100644 --- a/src/nativeServices/serviceInstall.test.ts +++ b/src/nativeServices/serviceInstall.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { installNativeServiceCandidate } from "./serviceInstall.js"; +import { + installNativeServiceCandidate, + nativeServiceInstallFailureNeedsPathAdvice, +} from "./serviceInstall.js"; import type { NativeServiceAuthoritativeProbe, NativeServicePlan, @@ -76,6 +79,41 @@ describe("native service install orchestration", () => { expect(replaceServices).toHaveBeenCalledWith(expect.objectContaining({ mode: "production" })); }); + it("validates manager and shell readiness without executing configured overrides", async () => { + const writeInitialConfig = vi.fn<() => Promise>(() => Promise.resolve()); + const replaceServices = vi.fn<() => Promise>(() => Promise.resolve()); + const requests: NativeServiceProbeRequest[] = []; + const configuredInput: ProductionNativeServicePlanInput = { + ...productionInput, + executables: { + sessiond: { ...productionInput.executables.sessiond, configuredCommand: "custom-sessiond --flag" }, + web: { ...productionInput.executables.web, configuredCommand: "custom-web --flag" }, + }, + }; + + const result = await installNativeServiceCandidate( + { mode: "production", input: configuredInput }, + { + probe: { + run: (request) => { + requests.push(request); + return Promise.resolve({ kind: "infrastructure-failure", reason: "manager", message: "manager unavailable" }); + }, + }, + fileExists: () => false, + writeInitialConfig, + replaceServices, + }, + ); + + expect(result).toMatchObject({ ok: false, failure: { kind: "plan-validation" } }); + if (result.ok) throw new Error("Expected manager validation failure"); + expect(nativeServiceInstallFailureNeedsPathAdvice(result.failure)).toBe(false); + expect(requests).toEqual([expect.objectContaining({ purpose: "plan-validation", prerequisites: [] })]); + expect(writeInitialConfig).not.toHaveBeenCalled(); + expect(replaceServices).not.toHaveBeenCalled(); + }); + it("does not make durable changes when exact plan requirements are unsatisfied", async () => { const writeInitialConfig = vi.fn<() => Promise>(() => Promise.resolve()); const replaceServices = vi.fn<() => Promise>(() => Promise.resolve()); @@ -99,6 +137,7 @@ describe("native service install orchestration", () => { if (result.ok || result.failure.kind !== "plan-validation") throw new Error("Expected validation failure"); expect(result.failure.failures).not.toHaveLength(0); expect(result.failure.failures.every((failure) => failure.kind === "prerequisite-unsatisfied")).toBe(true); + expect(nativeServiceInstallFailureNeedsPathAdvice(result.failure)).toBe(true); expect(writeInitialConfig).not.toHaveBeenCalled(); expect(replaceServices).not.toHaveBeenCalled(); }); @@ -135,6 +174,8 @@ describe("native service install orchestration", () => { }], }, }); + if (result.ok) throw new Error("Expected infrastructure failure"); + expect(nativeServiceInstallFailureNeedsPathAdvice(result.failure)).toBe(false); expect(writeInitialConfig).not.toHaveBeenCalled(); expect(replaceServices).not.toHaveBeenCalled(); }); diff --git a/src/nativeServices/serviceInstall.ts b/src/nativeServices/serviceInstall.ts index 108354e..aaa75d7 100644 --- a/src/nativeServices/serviceInstall.ts +++ b/src/nativeServices/serviceInstall.ts @@ -1,5 +1,6 @@ import { createDevelopmentNativeServicePlan, + nativeServicePrerequisiteNeedsPathAdvice, resolveProductionNativeServicePlan, validateNativeServicePlan, type DevelopmentNativeServicePlanInput, @@ -29,6 +30,16 @@ export type NativeServiceInstallResult = | { ok: true; plan: NativeServicePlan } | { ok: false; failure: NativeServiceInstallFailure }; +export function nativeServiceInstallFailureNeedsPathAdvice(failure: NativeServiceInstallFailure): boolean { + if (failure.kind === "plan-resolution") { + return failure.failures.every((item) => item.kind === "executable-unavailable") + && failure.failures.length > 0; + } + return failure.failures.some((item) => + item.kind === "prerequisite-unsatisfied" + && nativeServicePrerequisiteNeedsPathAdvice(item.prerequisite)); +} + /** * Keeps preflight effects ahead of durable install effects. The authoritative * probes may create bounded temporary artifacts, but they must clean those up diff --git a/src/nativeServices/servicePlan.test.ts b/src/nativeServices/servicePlan.test.ts index 4560bf5..c8b44eb 100644 --- a/src/nativeServices/servicePlan.test.ts +++ b/src/nativeServices/servicePlan.test.ts @@ -164,7 +164,14 @@ describe("production native service planning", () => { prerequisites: [], }, ]); - expect(planValidationProbeRequests(resolution.plan)).toEqual([]); + expect(planValidationProbeRequests(resolution.plan)).toEqual([{ + purpose: "plan-validation", + backend, + shell, + environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" }, + workingDirectory: null, + prerequisites: [], + }]); }); it("falls back per service to bundled entrypoints when named commands are unavailable", async () => { diff --git a/src/nativeServices/servicePlan.ts b/src/nativeServices/servicePlan.ts index 0a20a00..077fc5a 100644 --- a/src/nativeServices/servicePlan.ts +++ b/src/nativeServices/servicePlan.ts @@ -162,6 +162,7 @@ export interface DevelopmentNativeServicePlanInput { export interface NativeServicePlanDependencies { probe: NativeServiceAuthoritativeProbe; + /** Returns true only when the path exists and is a regular file. */ fileExists(path: string): boolean; } @@ -206,6 +207,10 @@ export type NativeServicePlanValidation = | { ok: true } | { ok: false; failures: readonly NativeServicePlanValidationFailure[] }; +export function nativeServicePrerequisiteNeedsPathAdvice(prerequisite: NativeServicePrerequisite): boolean { + return prerequisite.kind === "command-available" || prerequisite.kind === "node-version"; +} + export const nativeServiceManagerRefs: Readonly> = { sessiond: { systemdName: "pi-web-sessiond.service", @@ -386,7 +391,6 @@ export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServi export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] { const requests: (Omit & { prerequisites: NativeServicePrerequisite[] })[] = []; for (const service of plan.services) { - if (service.prerequisites.length === 0) continue; const existing = requests.find((request) => request.workingDirectory === service.workingDirectory && environmentsEqual(request.environment, service.environment)); @@ -558,7 +562,7 @@ function commandRequirement(serviceId: NativeServiceId, command: string): Native id: commandRequirementId(serviceId, command), kind: "command-available", command, - description: `${command} is available to the service shell`, + description: `${command} resolves to an external executable for the service shell`, }; } @@ -577,7 +581,7 @@ function readableFileRequirement(serviceId: NativeServiceId, path: string): Nati id: `${serviceId}.entrypoint`, kind: "readable-file", path, - description: `bundled entrypoint is readable: ${path}`, + description: `bundled entrypoint is a readable regular file: ${path}`, }; } diff --git a/src/nativeServices/serviceProbe.test.ts b/src/nativeServices/serviceProbe.test.ts index 657cb64..bd1b491 100644 --- a/src/nativeServices/serviceProbe.test.ts +++ b/src/nativeServices/serviceProbe.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { LaunchdNativeServiceProbe, + SpawnProbeCommandRunner, SystemdNativeServiceProbe, launchdProbePlist, + nativeServicePrerequisiteShellCheck, systemdRunArguments, type LaunchdProbeFileSystem, type ProbeCommandResult, @@ -74,6 +76,8 @@ describe("systemd authoritative native-service probe", () => { "--pipe", "--quiet", "--unit=pi-web-authoritative-probe-fixed.service", + "--property=RuntimeMaxSec=15s", + "--property=TimeoutStopSec=5s", "--setenv=PI_WEB_CONFIG=/home/user/config with space.json", "--working-directory=/checkout with space", "/usr/bin/env", @@ -91,7 +95,7 @@ describe("systemd authoritative native-service probe", () => { outcomes: [{ prerequisiteId: "sessiond.command.npm", status: "unsatisfied", - detail: "npm was not found in the native service environment.", + detail: "npm did not resolve to an external executable in the native service environment.", }], }); @@ -106,7 +110,7 @@ describe("systemd authoritative native-service probe", () => { const runner = queuedRunner([ { kind: "timeout", stdout: "", stderr: "" }, completed(0), - completed(0), + completed(0, "not-found\n"), ]); const probe = new SystemdNativeServiceProbe({ commandRunner: runner, @@ -125,7 +129,7 @@ describe("systemd authoritative native-service probe", () => { const runner = queuedRunner([ { kind: "timeout", stdout: "", stderr: "" }, completed(0), - completed(1, "", "unit still loaded"), + completed(0, "loaded\n", "unit still loaded"), ]); const probe = new SystemdNativeServiceProbe({ commandRunner: runner, @@ -139,22 +143,43 @@ describe("systemd authoritative native-service probe", () => { expect(runner.calls.map(({ command, args }) => [command, ...args.slice(0, 3)])).toEqual([ ["systemd-run", "--user", "--wait", "--collect"], ["systemctl", "--user", "stop", "pi-web-authoritative-probe-fixed.service"], - ["systemctl", "--user", "reset-failed", "pi-web-authoritative-probe-fixed.service"], + ["systemctl", "--user", "show", "pi-web-authoritative-probe-fixed.service"], ]); }); }); +describe("spawn probe command runner", () => { + it("bounds captured command output", async () => { + const runner = new SpawnProbeCommandRunner(); + const result = await runner.run( + process.execPath, + ["-e", "process.stdout.write('x'.repeat(2 * 1024 * 1024))"], + 5_000, + ); + + expect(result).toMatchObject({ kind: "output-limit" }); + expect(result.stdout.length).toBeLessThanOrEqual(1024 * 1024); + }); + + it("settles a timeout without waiting for inherited pipes to close", async () => { + const runner = new SpawnProbeCommandRunner(); + const childScript = [ + "const { spawn } = require('node:child_process');", + "const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { stdio: ['ignore', 'inherit', 'inherit'] });", + "child.unref();", + ].join(" "); + const startedAt = performance.now(); + + await expect(runner.run(process.execPath, ["-e", childScript], 20)).resolves.toMatchObject({ kind: "timeout" }); + expect(performance.now() - startedAt).toBeLessThan(500); + }); +}); + describe("launchd authoritative native-service probe", () => { it("bootstraps a uniquely labelled one-shot agent in gui/ and always cleans it up", async () => { - const runner = queuedRunner([ - completed(0), - completed(0, "state = running\n"), - completed(0, "state = not running\nlast exit code = 0\n"), - completed(0), - ]); + const runner = queuedRunner([completed(0), completed(0)]); const fileSystem = launchdFileSystem({ - "/tmp/probe/stdout.log": marker("sessiond.command.npm", "satisfied"), - "/tmp/probe/stderr.log": "", + "/tmp/probe/result.log": marker("sessiond.command.npm", "satisfied"), }); let now = 0; const probe = new LaunchdNativeServiceProbe({ @@ -172,28 +197,28 @@ describe("launchd authoritative native-service probe", () => { await expect(probe.run(request("launchd"))).resolves.toMatchObject({ kind: "completed" }); expect(runner.calls.map(({ command, args }) => [command, ...args])).toEqual([ ["launchctl", "bootstrap", "gui/501", "/tmp/probe/probe.plist"], - ["launchctl", "print", "gui/501/com.pi-web.authoritative-probe.501.fixed"], - ["launchctl", "print", "gui/501/com.pi-web.authoritative-probe.501.fixed"], ["launchctl", "bootout", "gui/501/com.pi-web.authoritative-probe.501.fixed"], ]); expect(fileSystem.writeFile).toHaveBeenCalledWith( "/tmp/probe/probe.plist", expect.stringContaining("/bin/zsh"), + 0o600, ); expect(fileSystem.writeFile).toHaveBeenCalledWith( "/tmp/probe/probe.plist", expect.stringContaining("WorkingDirectory\n /checkout with space"), + 0o600, + ); + expect(fileSystem.writeFile).toHaveBeenCalledWith( + "/tmp/probe/probe.plist", + expect.stringContaining("/bin/mv '/tmp/probe/result.pending' '/tmp/probe/result.log'"), + 0o600, ); expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); }); it("times out deterministically, boots out the agent, and removes temporary files", async () => { - const runner = queuedRunner([ - completed(0), - completed(0, "state = running\n"), - completed(0, "state = running\n"), - completed(0), - ]); + const runner = queuedRunner([completed(0), completed(0)]); const fileSystem = launchdFileSystem({}); let now = 0; const probe = new LaunchdNativeServiceProbe({ @@ -219,15 +244,34 @@ describe("launchd authoritative native-service probe", () => { expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); }); + it("bounds a stalled result-file read before cleaning up", async () => { + const runner = queuedRunner([completed(0), completed(0)]); + const fileSystem = launchdFileSystem({}); + fileSystem.readOptionalFile.mockReturnValueOnce(new Promise(() => undefined)); + const probe = new LaunchdNativeServiceProbe({ + commandRunner: runner, + fileSystem, + uid: 502, + createUniqueId: () => "fixed", + now: () => 0, + sleep: () => Promise.resolve(), + probeTimeoutMs: 10, + pollIntervalMs: 1, + commandTimeoutMs: 100, + }); + + await expect(probe.run(request("launchd"))).resolves.toMatchObject({ + kind: "infrastructure-failure", + reason: "timeout", + }); + expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); + }); + it("surfaces cleanup failure instead of returning an otherwise successful probe", async () => { - const runner = queuedRunner([ - completed(0), - completed(0, "state = not running\nlast exit code = 0\n"), - completed(1, "", "bootout denied"), - ]); + const runner = queuedRunner([completed(0), completed(1, "", "bootout denied")]); const fileSystem = launchdFileSystem({ - "/tmp/probe/stdout.log": marker("sessiond.command.npm", "satisfied"), - "/tmp/probe/stderr.log": "", + "/tmp/probe/result.log": marker("sessiond.command.npm", "satisfied"), }); const probe = new LaunchdNativeServiceProbe({ commandRunner: runner, @@ -247,10 +291,9 @@ describe("launchd authoritative native-service probe", () => { expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); }); - it("checks and boots out a label when bootstrap itself times out", async () => { + it("boots out a label when bootstrap itself times out", async () => { const runner = queuedRunner([ { kind: "timeout", stdout: "", stderr: "" }, - completed(0, "state = running\n"), completed(0), ]); const fileSystem = launchdFileSystem({}); @@ -270,12 +313,15 @@ describe("launchd authoritative native-service probe", () => { kind: "infrastructure-failure", reason: "timeout", }); - expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "print", "bootout"]); + expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]); expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); }); - it("removes temporary files when bootstrap fails without booting out an unloaded label", async () => { - const runner = queuedRunner([completed(1, "", "bootstrap denied")]); + it("treats an explicit not-loaded bootout response as successful cleanup after bootstrap fails", async () => { + const runner = queuedRunner([ + completed(1, "", "bootstrap denied"), + completed(3, "", "Could not find service in domain"), + ]); const fileSystem = launchdFileSystem({}); const probe = new LaunchdNativeServiceProbe({ commandRunner: runner, @@ -292,17 +338,13 @@ describe("launchd authoritative native-service probe", () => { const result = await probe.run(request("launchd")); expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "manager" }); expect(result.kind === "infrastructure-failure" && result.message).toContain("bootstrap denied"); - expect(runner.calls).toHaveLength(1); + expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]); 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({}); + it("cleans a loaded label after malformed private result output", async () => { + const runner = queuedRunner([completed(0), completed(0)]); + const fileSystem = launchdFileSystem({ "/tmp/probe/result.log": "malformed result" }); const probe = new LaunchdNativeServiceProbe({ commandRunner: runner, fileSystem, @@ -323,13 +365,10 @@ describe("launchd authoritative native-service probe", () => { 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), - ]); + it("cleans a loaded label when the private result cannot be read", async () => { + const runner = queuedRunner([completed(0), completed(0)]); const fileSystem = launchdFileSystem({}); + fileSystem.readOptionalFile.mockRejectedValueOnce(new Error("read denied")); const probe = new LaunchdNativeServiceProbe({ commandRunner: runner, fileSystem, @@ -344,13 +383,16 @@ describe("launchd authoritative native-service probe", () => { 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(result.kind === "infrastructure-failure" && result.message).toContain("Could not read launchd probe result"); 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 runner = queuedRunner([ + completed(1, "", "bootstrap denied"), + completed(3, "", "Could not find service in domain"), + ]); const fileSystem = launchdFileSystem({}); fileSystem.removeDirectory.mockRejectedValueOnce(new Error("rm denied")); const probe = new LaunchdNativeServiceProbe({ @@ -372,28 +414,70 @@ describe("launchd authoritative native-service probe", () => { }); describe("probe service definitions", () => { + it("requires external executables instead of accepting shell functions or aliases", () => { + const commandRequirement = request().prerequisites[0]; + if (commandRequirement === undefined) throw new Error("Expected a command prerequisite"); + const bashCheck = nativeServicePrerequisiteShellCheck("bash", commandRequirement); + expect(bashCheck).toContain("case \"$pi_web_probe_executable\" in */*)"); + expect(bashCheck).toContain("test -f \"$pi_web_probe_executable\""); + expect(bashCheck).toContain("test -x \"$pi_web_probe_executable\""); + + const fishCheck = nativeServicePrerequisiteShellCheck("fish", commandRequirement); + expect(fishCheck).toContain("string match -q '*/*'"); + expect(fishCheck).toContain("test -f $pi_web_probe_executable[1]"); + expect(fishCheck).toContain("test -x $pi_web_probe_executable[1]"); + }); + + it("invokes the resolved external Node executable for version checks", () => { + const check = nativeServicePrerequisiteShellCheck("zsh", { + id: "sessiond.node", + kind: "node-version", + command: "node", + minimumMajor: 22, + description: "node >= 22", + }); + expect(check).toContain("\"$pi_web_probe_executable\" '-e'"); + expect(check).not.toContain("&& node -e"); + }); + + it("requires bundled entrypoints to be readable regular files", () => { + const check = nativeServicePrerequisiteShellCheck("bash", { + id: "sessiond.entrypoint", + kind: "readable-file", + path: "/package/server.js", + description: "entrypoint", + }); + expect(check).toBe("test -f '/package/server.js' && test -r '/package/server.js'"); + }); + it("renders backend inputs without inheriting the caller PATH", () => { const probeRequest = request(); - expect(systemdRunArguments(probeRequest, "probe.service", "echo ok")).not.toContain(expect.stringContaining("PATH=")); + const systemdArguments = systemdRunArguments(probeRequest, "probe.service", "echo ok"); + expect(systemdArguments.some((argument) => argument.includes("PATH="))).toBe(false); const plist = launchdProbePlist(probeRequest, "com.example.probe", "echo ok", "/tmp/out", "/tmp/err"); expect(plist).not.toContain("PATH"); expect(plist).toContain("PI_WEB_CONFIG"); + expect(plist).toContain("HardResourceLimits"); + }); + + it("escapes manager-side substitutions in the systemd probe payload", () => { + const args = systemdRunArguments(request(), "probe.service", "test -r '/tmp/$HOME/%h'"); + expect(args.at(-1)).toBe("test -r '/tmp/$$HOME/%h'"); }); }); function launchdFileSystem(contents: Record): LaunchdProbeFileSystem & { writeFile: ReturnType>; + readOptionalFile: ReturnType>; removeDirectory: ReturnType>; } { const writeFileMock = vi.fn(() => Promise.resolve()); + const readOptionalFileMock = vi.fn((path) => Promise.resolve(contents[path] ?? null)); const removeDirectoryMock = vi.fn(() => Promise.resolve()); return { createTemporaryDirectory: () => Promise.resolve("/tmp/probe"), writeFile: writeFileMock, - readFile: (path) => { - const content = contents[path]; - return content === undefined ? Promise.reject(new Error(`missing ${path}`)) : Promise.resolve(content); - }, + readOptionalFile: readOptionalFileMock, removeDirectory: removeDirectoryMock, }; } diff --git a/src/nativeServices/serviceProbe.ts b/src/nativeServices/serviceProbe.ts index 97cd1bc..3ff8b83 100644 --- a/src/nativeServices/serviceProbe.ts +++ b/src/nativeServices/serviceProbe.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir, userInfo } from "node:os"; import { join } from "node:path"; +import { performance } from "node:perf_hooks"; import { randomUUID } from "node:crypto"; import type { NativeServiceAuthoritativeProbe, @@ -15,7 +16,8 @@ import type { export type ProbeCommandResult = | { kind: "completed"; status: number; stdout: string; stderr: string } | { kind: "timeout"; stdout: string; stderr: string } - | { kind: "spawn-failure"; message: string; stdout: string; stderr: string }; + | { kind: "spawn-failure"; message: string; stdout: string; stderr: string } + | { kind: "output-limit"; stdout: string; stderr: string }; export interface ProbeCommandRunner { run(command: string, args: readonly string[], timeoutMs: number): Promise; @@ -23,8 +25,8 @@ export interface ProbeCommandRunner { export interface LaunchdProbeFileSystem { createTemporaryDirectory(prefix: string): Promise; - writeFile(path: string, contents: string): Promise; - readFile(path: string): Promise; + writeFile(path: string, contents: string, mode: number): Promise; + readOptionalFile(path: string): Promise; removeDirectory(path: string): Promise; } @@ -48,6 +50,8 @@ export interface LaunchdProbeDependencies extends CommonProbeDependencies { const defaultCommandTimeoutMs = 15_000; const defaultProbeTimeoutMs = 15_000; const defaultPollIntervalMs = 50; +const maxCapturedCommandOutputBytes = 1024 * 1024; +const maxLaunchdProbeFileBytes = 1024 * 1024; export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProbe { public constructor(private readonly dependencies: SystemdProbeDependencies) {} @@ -64,12 +68,12 @@ export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProb const args = systemdRunArguments(request, unitName, command); const result = await this.dependencies.commandRunner.run("systemd-run", args, this.dependencies.commandTimeoutMs); - if (result.kind === "timeout") { + if (result.kind === "timeout" || result.kind === "output-limit") { const cleanupFailure = await this.cleanupTimedOutUnit(unitName); - return cleanupFailure ?? infrastructureFailure( - "timeout", - `Timed out waiting for transient systemd unit ${unitName}.`, - ); + if (cleanupFailure !== null) return cleanupFailure; + return result.kind === "timeout" + ? infrastructureFailure("timeout", `Timed out waiting for transient systemd unit ${unitName}.`) + : infrastructureFailure("manager", `Transient systemd probe ${unitName} exceeded the output limit.`); } if (result.kind === "spawn-failure") { return infrastructureFailure("manager", `Could not start systemd-run: ${result.message}`); @@ -89,18 +93,19 @@ export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProb ["--user", "stop", unitName], this.dependencies.commandTimeoutMs, ); - if (stop.kind !== "completed" || stop.status !== 0) { - return infrastructureFailure("cleanup", `Could not stop timed-out transient systemd unit ${unitName}: ${commandFailureDetail(stop)}`); - } - const reset = await this.dependencies.commandRunner.run( + const inspected = await this.dependencies.commandRunner.run( "systemctl", - ["--user", "reset-failed", unitName], + ["--user", "show", unitName, "--property=LoadState", "--value"], this.dependencies.commandTimeoutMs, ); - if (reset.kind !== "completed" || reset.status !== 0) { - return infrastructureFailure("cleanup", `Could not collect timed-out transient systemd unit ${unitName}: ${commandFailureDetail(reset)}`); + if (inspected.kind === "completed" && inspected.status === 0 && inspected.stdout.trim() === "not-found") { + return null; } - return null; + const details = [ + `stop: ${commandFailureDetail(stop)}`, + `load state: ${commandFailureDetail(inspected)}`, + ].join("; "); + return infrastructureFailure("cleanup", `Could not confirm cleanup of timed-out transient systemd unit ${unitName}: ${details}`); } } @@ -128,10 +133,18 @@ export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProb const plistPath = join(directory, "probe.plist"); const stdoutPath = join(directory, "stdout.log"); const stderrPath = join(directory, "stderr.log"); - const command = prerequisiteProbeCommand(request.shell.name, request.prerequisites, outputPrefix); + const pendingResultPath = join(directory, "result.pending"); + const resultPath = join(directory, "result.log"); + const command = prerequisiteProbeCommand( + request.shell.name, + request.prerequisites, + outputPrefix, + { pendingPath: pendingResultPath, completedPath: resultPath }, + ); await this.dependencies.fileSystem.writeFile( plistPath, launchdProbePlist(request, label, command, stdoutPath, stderrPath), + 0o600, ); const bootstrap = await this.dependencies.commandRunner.run( @@ -140,11 +153,11 @@ export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProb this.dependencies.commandTimeoutMs, ); if (bootstrap.kind !== "completed" || bootstrap.status !== 0) { - bootstrapState = bootstrap.kind === "timeout" ? "uncertain" : "not-loaded"; + bootstrapState = bootstrap.kind === "spawn-failure" ? "not-loaded" : "uncertain"; result = commandInfrastructureFailure("bootstrap launchd probe", bootstrap); } else { bootstrapState = "loaded"; - result = await this.waitForResult(target, stdoutPath, stderrPath, request.prerequisites, outputPrefix); + result = await this.waitForResult(target, resultPath, request.prerequisites, outputPrefix); } } catch (error: unknown) { result = infrastructureFailure("manager", `Could not prepare launchd probe: ${errorMessage(error)}`); @@ -156,48 +169,25 @@ export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProb private async waitForResult( target: string, - stdoutPath: string, - stderrPath: string, + resultPath: string, prerequisites: readonly NativeServicePrerequisite[], outputPrefix: string, ): Promise { const deadline = this.dependencies.now() + this.dependencies.probeTimeoutMs; while (this.dependencies.now() < deadline) { - const printed = await this.dependencies.commandRunner.run( - "launchctl", - ["print", target], - this.dependencies.commandTimeoutMs, + const remainingMs = Math.max(0, deadline - this.dependencies.now()); + const boundedRead = await readOptionalFileBounded( + this.dependencies.fileSystem, + resultPath, + remainingMs, ); - if (printed.kind !== "completed" || printed.status !== 0) { - return commandInfrastructureFailure("inspect launchd probe", printed); + if (boundedRead.kind === "deadline") break; + if (boundedRead.kind === "read-failure") { + return infrastructureFailure("manager", `Could not read launchd probe result: ${errorMessage(boundedRead.error)}`); } - - const state = launchdField(printed.stdout, "state"); - if (state === undefined) { - return infrastructureFailure("malformed-output", `launchctl returned no state for ${target}.`); - } - const lastExitCode = launchdIntegerField(printed.stdout, "last exit code"); - if (state === "not running" && lastExitCode !== undefined) { - let stdout: string; - let stderr: string; - try { - [stdout, stderr] = await Promise.all([ - this.dependencies.fileSystem.readFile(stdoutPath), - this.dependencies.fileSystem.readFile(stderrPath), - ]); - } catch (error: unknown) { - return infrastructureFailure("manager", `Could not read launchd probe output: ${errorMessage(error)}`); - } - if (lastExitCode !== 0) { - return infrastructureFailure( - "manager", - `Launchd probe service exited with status ${String(lastExitCode)}: ${firstOutput(stderr, stdout, "no output")}`, - ); - } - return parseProbeOutput(stdout, prerequisites, outputPrefix); - } - - await this.dependencies.sleep(this.dependencies.pollIntervalMs); + if (boundedRead.output !== null) return parseProbeOutput(boundedRead.output, prerequisites, outputPrefix); + const pollDelayMs = Math.min(this.dependencies.pollIntervalMs, Math.max(0, deadline - this.dependencies.now())); + await this.dependencies.sleep(pollDelayMs); } return infrastructureFailure("timeout", `Timed out waiting for launchd probe ${target}.`); } @@ -208,28 +198,21 @@ export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProb bootstrapState: "not-loaded" | "loaded" | "uncertain", ): Promise { const failures: string[] = []; - let shouldBootout = bootstrapState === "loaded"; - if (bootstrapState === "uncertain") { - const inspection = await this.dependencies.commandRunner.run( - "launchctl", - ["print", target], - this.dependencies.commandTimeoutMs, - ); - if (inspection.kind === "completed") { - shouldBootout = inspection.status === 0; - } else { - // If launchctl cannot tell us whether a timed-out bootstrap loaded the - // label, bootout is the only operation that can make cleanup certain. - shouldBootout = true; - } - } + const shouldBootout = bootstrapState !== "not-loaded"; if (shouldBootout) { + // A failed or timed-out bootstrap may still have loaded the unique label. + // Bootout is the only race-free cleanup; an explicit not-loaded response + // is success when bootstrap completion was uncertain. + const absenceIsSuccess = bootstrapState === "uncertain"; const bootout = await this.dependencies.commandRunner.run( "launchctl", ["bootout", target], this.dependencies.commandTimeoutMs, ); - if (bootout.kind !== "completed" || bootout.status !== 0) { + if ( + (bootout.kind !== "completed" || bootout.status !== 0) + && !(absenceIsSuccess && launchdTargetNotLoaded(bootout)) + ) { failures.push(`bootout failed: ${commandFailureDetail(bootout)}`); } } @@ -258,7 +241,7 @@ export function createNativeServiceAuthoritativeProbe(): NativeServiceAuthoritat ...common, fileSystem: nodeLaunchdProbeFileSystem, uid: userInfo().uid, - now: Date.now, + now: performance.now.bind(performance), sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), probeTimeoutMs: defaultProbeTimeoutMs, pollIntervalMs: defaultPollIntervalMs, @@ -280,15 +263,21 @@ export function systemdRunArguments( "--pipe", "--quiet", `--unit=${unitName}`, + "--property=RuntimeMaxSec=15s", + "--property=TimeoutStopSec=5s", ...Object.entries(request.environment).map(([key, value]) => `--setenv=${key}=${value}`), ...(request.workingDirectory === null ? [] : [`--working-directory=${request.workingDirectory}`]), "/usr/bin/env", - request.shell.executable, + escapeSystemdCommandExpansion(request.shell.executable), "-lc", - shellCommand, + escapeSystemdCommandExpansion(shellCommand), ]; } +function escapeSystemdCommandExpansion(value: string): string { + return value.replaceAll("$", () => "$$"); +} + export function launchdProbePlist( request: NativeServiceProbeRequest, label: string, @@ -316,36 +305,63 @@ ${argumentsXml} ${workingDirectoryXml}${environmentXml} RunAtLoad + HardResourceLimits + + FileSize + ${String(maxLaunchdProbeFileBytes)} + ${plistString("StandardOutPath", stdoutPath)}${plistString("StandardErrorPath", stderrPath)} `; } -class SpawnProbeCommandRunner implements ProbeCommandRunner { +export class SpawnProbeCommandRunner implements ProbeCommandRunner { public run(command: string, args: readonly string[], timeoutMs: number): Promise { return new Promise((resolve) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; + let capturedBytes = 0; let spawnFailure: string | null = null; - let timedOut = false; + let settled = false; + + const finish = (result: ProbeCommandResult, terminate: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (terminate) { + child.kill("SIGKILL"); + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + } + resolve(result); + }; + const capture = (stream: "stdout" | "stderr", chunk: string): void => { + if (settled) return; + const bytes = Buffer.byteLength(chunk); + if (capturedBytes + bytes > maxCapturedCommandOutputBytes) { + finish({ kind: "output-limit", stdout, stderr }, true); + return; + } + capturedBytes += bytes; + if (stream === "stdout") stdout += chunk; + else stderr += chunk; + }; + child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { stdout += chunk; }); - child.stderr.on("data", (chunk: string) => { stderr += chunk; }); + child.stdout.on("data", (chunk: string) => { capture("stdout", chunk); }); + child.stderr.on("data", (chunk: string) => { capture("stderr", chunk); }); child.on("error", (error) => { spawnFailure = error.message; }); const timeout = setTimeout(() => { - timedOut = true; - child.kill("SIGKILL"); + finish({ kind: "timeout", stdout, stderr }, true); }, timeoutMs); child.on("close", (status) => { - clearTimeout(timeout); - if (timedOut) { - resolve({ kind: "timeout", stdout, stderr }); - } else if (spawnFailure !== null) { - resolve({ kind: "spawn-failure", message: spawnFailure, stdout, stderr }); + if (spawnFailure !== null) { + finish({ kind: "spawn-failure", message: spawnFailure, stdout, stderr }, false); } else { - resolve({ kind: "completed", status: status ?? 1, stdout, stderr }); + finish({ kind: "completed", status: status ?? 1, stdout, stderr }, false); } }); }); @@ -354,49 +370,109 @@ class SpawnProbeCommandRunner implements ProbeCommandRunner { const nodeLaunchdProbeFileSystem: LaunchdProbeFileSystem = { createTemporaryDirectory: (prefix) => mkdtemp(prefix), - writeFile: (path, contents) => writeFile(path, contents, "utf8"), - readFile: (path) => readFile(path, "utf8"), + writeFile: (path, contents, mode) => writeFile(path, contents, { encoding: "utf8", mode }), + readOptionalFile: async (path) => { + try { + return await readFile(path, "utf8"); + } catch (error: unknown) { + if (isNodeErrorWithCode(error, "ENOENT")) return null; + throw error; + } + }, removeDirectory: (path) => rm(path, { recursive: true, force: true }), }; +function readOptionalFileBounded( + fileSystem: LaunchdProbeFileSystem, + path: string, + timeoutMs: number, +): Promise< + | { kind: "read"; output: string | null } + | { kind: "read-failure"; error: unknown } + | { kind: "deadline" } +> { + return new Promise((resolve) => { + let settled = false; + const finish = (result: + | { kind: "read"; output: string | null } + | { kind: "read-failure"; error: unknown } + | { kind: "deadline" }): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(result); + }; + const timeout = setTimeout(() => { finish({ kind: "deadline" }); }, timeoutMs); + void fileSystem.readOptionalFile(path).then( + (output) => { finish({ kind: "read", output }); }, + (error: unknown) => { finish({ kind: "read-failure", error }); }, + ); + }); +} + function prerequisiteProbeCommand( shell: NativeServiceShellName, prerequisites: readonly NativeServicePrerequisite[], outputPrefix: string, + resultFiles?: { pendingPath: string; completedPath: string }, ): string { - return prerequisites.map((prerequisite) => { + const markerPath = resultFiles?.pendingPath; + const checks = prerequisites.map((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"); + const satisfied = markerCommand(shell, outputPrefix, encodedId, "satisfied", markerPath); + const unsatisfied = markerCommand(shell, outputPrefix, encodedId, "unsatisfied", markerPath); return `${check} >/dev/null 2>&1 && ${satisfied} || ${unsatisfied}`; }).join("; ") || ":"; + if (resultFiles === undefined) return checks; + const pending = shellQuote(shell, resultFiles.pendingPath); + const completed = shellQuote(shell, resultFiles.completedPath); + return `printf '%s' '' > ${pending}; ${checks}; /bin/mv ${pending} ${completed}`; } export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellName, prerequisite: NativeServicePrerequisite): string { switch (prerequisite.kind) { case "command-available": - return `command -v ${shellQuote(shell, prerequisite.command)}`; + return externalExecutableShellCheck(shell, prerequisite.command); case "node-version": { const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`; - return `node -e ${shellQuote(shell, script)}`; + return externalExecutableShellCheck(shell, "node", ["-e", script]); + } + case "readable-file": { + const path = shellQuote(shell, prerequisite.path); + return `test -f ${path} && test -r ${path}`; } - case "readable-file": - return `test -r ${shellQuote(shell, prerequisite.path)}`; case "package-scripts": { const script = "const p=require(process.argv[1]);const names=process.argv.slice(2);process.exit(names.every((name)=>typeof p.scripts?.[name]==='string')?0:1)"; - return ["node", "-e", shellQuote(shell, script), shellQuote(shell, prerequisite.packageJsonPath), ...prerequisite.scripts.map((name) => shellQuote(shell, name))].join(" "); + return externalExecutableShellCheck(shell, "node", ["-e", script, prerequisite.packageJsonPath, ...prerequisite.scripts]); } } } +function externalExecutableShellCheck( + shell: NativeServiceShellName, + command: string, + arguments_: readonly string[] = [], +): string { + const quotedCommand = shellQuote(shell, command); + const quotedArguments = arguments_.map((argument) => shellQuote(shell, argument)).join(" "); + if (shell === "fish") { + const invocation = quotedArguments === "" ? "" : `; and $pi_web_probe_executable[1] ${quotedArguments}`; + return `set -l pi_web_probe_executable (command -v ${quotedCommand}); and test (count $pi_web_probe_executable) -eq 1; and string match -q '*/*' -- $pi_web_probe_executable[1]; and test -f $pi_web_probe_executable[1]; and test -x $pi_web_probe_executable[1]${invocation}`; + } + const invocation = quotedArguments === "" ? "" : ` && "$pi_web_probe_executable" ${quotedArguments}`; + return `pi_web_probe_executable=$(command -v ${quotedCommand}) && case "$pi_web_probe_executable" in */*) test -f "$pi_web_probe_executable" && test -x "$pi_web_probe_executable"${invocation};; *) false;; esac`; +} + function markerCommand( shell: NativeServiceShellName, outputPrefix: string, encodedId: string, status: "satisfied" | "unsatisfied", + outputPath?: string, ): string { - return `printf '%s\\t%s\\t%s\\n' ${shellQuote(shell, outputPrefix)} ${shellQuote(shell, encodedId)} ${shellQuote(shell, status)}`; + const redirect = outputPath === undefined ? "" : ` >> ${shellQuote(shell, outputPath)}`; + return `printf '%s\\t%s\\t%s\\n' ${shellQuote(shell, outputPrefix)} ${shellQuote(shell, encodedId)} ${shellQuote(shell, status)}${redirect}`; } function parseProbeOutput( @@ -440,11 +516,11 @@ function parseProbeOutput( function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string { switch (prerequisite.kind) { case "command-available": - return `${prerequisite.command} was not found in the native service environment.`; + return `${prerequisite.command} did not resolve to an external executable in the native service environment.`; case "node-version": return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`; case "readable-file": - return `${prerequisite.path} was not readable in the native service environment.`; + return `${prerequisite.path} was not a readable regular file in the native service environment.`; case "package-scripts": return `${prerequisite.packageJsonPath} did not provide scripts ${prerequisite.scripts.join(", ")} in the native service environment.`; } @@ -461,18 +537,9 @@ function safeUniqueId(value: string): string { return safe === "" ? "probe" : safe; } -function launchdField(output: string, field: string): string | undefined { - return new RegExp(`^\\s*${escapeRegExp(field)}\\s*=\\s*(.+)$`, "mu").exec(output)?.[1]?.trim(); -} - -function launchdIntegerField(output: string, field: string): number | undefined { - const value = launchdField(output, field); - if (value === undefined || !/^-?\d+$/u.test(value)) return undefined; - return Number(value); -} - -function escapeRegExp(value: string): string { - return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +function launchdTargetNotLoaded(result: ProbeCommandResult): boolean { + if (result.kind !== "completed" || result.status === 0) return false; + return /(?:could not find (?:specified )?service|service not found|no such process)/iu.test(`${result.stderr}\n${result.stdout}`); } function plistString(key: string, value: string, indent = " "): string { @@ -496,6 +563,7 @@ function commandInfrastructureFailure(action: string, result: ProbeCommandResult function commandFailureDetail(result: ProbeCommandResult): string { if (result.kind === "timeout") return "command timed out"; if (result.kind === "spawn-failure") return result.message; + if (result.kind === "output-limit") return "command output exceeded the capture limit"; return firstOutput(result.stderr, result.stdout, `exit status ${String(result.status)}`); } @@ -517,3 +585,7 @@ function firstOutput(...values: string[]): string { function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/src/nativeServices/serviceRendering.test.ts b/src/nativeServices/serviceRendering.test.ts index eea8868..617d407 100644 --- a/src/nativeServices/serviceRendering.test.ts +++ b/src/nativeServices/serviceRendering.test.ts @@ -34,12 +34,33 @@ describe("native service rendering", () => { expect(unit).toContain("Description=PI WEB UI dev server"); expect(unit).toContain("After=pi-web-sessiond.service\nWants=pi-web-sessiond.service"); - expect(unit).toContain('WorkingDirectory="/checkout with space"'); + expect(unit).toContain("WorkingDirectory=/checkout\\x20with\\x20space"); expect(unit).toContain('Environment="PI_WEB_CONFIG=/home/user/config with \\"quote\\".json"'); - expect(unit).toContain("ExecStart=/usr/bin/env /bin/zsh -lc 'exec /usr/bin/env bash -c '\\''trap"); + expect(unit).toContain('ExecStart=/usr/bin/env "/bin/zsh" -lc "exec /usr/bin/env bash -c \'trap \\"kill 0\\" EXIT;'); expect(unit).toContain("Restart=no"); }); + it("escapes systemd specifiers and line controls without changing directives", () => { + const plan = createDevelopmentNativeServicePlan({ + backend: { kind: "systemd", label: "systemd" }, + shell: { + name: "bash", + executable: "/shell $HOME/%h/bash", + source: "detected", + detectedExecutable: "/shell $HOME/%h/bash", + }, + environment: { PI_WEB_CONFIG: "/config/%h\nEnvironment=INJECTED=yes" }, + workingDirectory: "/checkout %h\nwith newline", + packageJsonPath: "/checkout/package.json", + }); + const unit = renderSystemdUnit(plan, planService(plan, 0)); + + expect(unit).toContain("WorkingDirectory=/checkout\\x20%%h\\nwith\\x20newline"); + expect(unit).toContain('Environment="PI_WEB_CONFIG=/config/%%h\\nEnvironment=INJECTED=yes"'); + expect(unit).toContain('ExecStart=/usr/bin/env "/shell $$HOME/%%h/bash"'); + expect(unit.match(/^Environment=/gmu)).toHaveLength(1); + }); + it("renders launchd entirely from the canonical plan", () => { const plan = developmentPlan("launchd"); const plist = renderLaunchdPlist(plan, planService(plan, 0), "/logs"); diff --git a/src/nativeServices/serviceRendering.ts b/src/nativeServices/serviceRendering.ts index 39e1d4c..a421f9c 100644 --- a/src/nativeServices/serviceRendering.ts +++ b/src/nativeServices/serviceRendering.ts @@ -3,7 +3,6 @@ import type { NativeServiceId, NativeServicePlan, NativeServicePlanService, - NativeServiceShellName, } from "./servicePlan.js"; export function renderSystemdUnit( @@ -14,7 +13,7 @@ export function renderSystemdUnit( assertBackend(plan, "systemd"); const workingDirectory = service.workingDirectory === null ? "" - : `WorkingDirectory=${systemdQuotedValue(service.workingDirectory)}\n`; + : `WorkingDirectory=${systemdPathValue(service.workingDirectory)}\n`; const restart = service.restart === "on-failure" ? "Restart=on-failure\nRestartSec=2\n" : "Restart=no\n"; @@ -22,7 +21,7 @@ export function renderSystemdUnit( Description=${service.description} ${systemdDependencyLine(plan, "After", service.after)}${systemdDependencyLine(plan, "Wants", service.wants)}[Service] Type=simple -${workingDirectory}${systemdEnvironmentLines(service.environment)}ExecStart=/usr/bin/env ${plan.shell.executable} -lc ${systemdServiceShellQuote(plan.shell.name, service.shellCommand)} +${workingDirectory}${systemdEnvironmentLines(service.environment)}ExecStart=/usr/bin/env ${systemdExecArgument(plan.shell.executable)} -lc ${systemdExecArgument(service.shellCommand)} ${restart} [Install] WantedBy=default.target @@ -83,20 +82,37 @@ function systemdDependencyLine( function systemdEnvironmentLines(environment: Readonly>): string { return Object.entries(environment) - .map(([key, value]) => `Environment="${systemdEscape(key)}=${systemdEscape(value)}"\n`) + .map(([key, value]) => `Environment=${systemdQuotedDirectiveValue(`${key}=${value}`)}\n`) .join(""); } -function systemdServiceShellQuote(shell: NativeServiceShellName, value: string): string { - return shellQuote(shell, value.replaceAll("%", "%%").replaceAll("$", "$$")); +function systemdExecArgument(value: string): string { + return `"${systemdEscape(value.replaceAll("%", "%%").replaceAll("$", () => "$$"), false)}"`; } -function systemdQuotedValue(value: string): string { - return `"${systemdEscape(value)}"`; +function systemdQuotedDirectiveValue(value: string): string { + return `"${systemdEscape(value.replaceAll("%", "%%"), false)}"`; } -function systemdEscape(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +function systemdPathValue(value: string): string { + return systemdEscape(value.replaceAll("%", "%%"), true); +} + +function systemdEscape(value: string, escapeSpaces: boolean): string { + let escaped = ""; + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (character === "\\") escaped += "\\\\"; + else if (character === '"') escaped += escapeSpaces ? "\\x22" : '\\"'; + else if (character === "'" && escapeSpaces) escaped += "\\x27"; + else if (character === " " && escapeSpaces) escaped += "\\x20"; + else if (character === "\n") escaped += "\\n"; + else if (character === "\r") escaped += "\\r"; + else if (character === "\t") escaped += "\\t"; + else if (code < 0x20 || code === 0x7f) escaped += `\\x${code.toString(16).padStart(2, "0")}`; + else escaped += character; + } + return escaped; } function plistProgramArguments(arguments_: readonly string[]): string { @@ -113,12 +129,6 @@ function plistString(key: string, value: string, indent = " "): string { return `${indent}${xmlEscape(key)}\n${indent}${xmlEscape(value)}\n`; } -function shellQuote(shell: NativeServiceShellName, value: string): string { - return shell === "fish" - ? `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'` - : `'${value.replaceAll("'", "'\\''")}'`; -} - function xmlEscape(value: string): string { return value .replaceAll("&", "&") From a8aee7b550e9d61dc71470a8cb84557ce05172fb Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 13:10:34 +0200 Subject: [PATCH 08/12] fix(cli): use POSIX native service paths --- src/nativeServices/serviceDoctor.test.ts | 15 ++++++++++++++- src/nativeServices/serviceDoctor.ts | 6 +++--- src/nativeServices/serviceRendering.test.ts | 3 ++- src/nativeServices/serviceRendering.ts | 4 ++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/nativeServices/serviceDoctor.test.ts b/src/nativeServices/serviceDoctor.test.ts index 3da5e8d..49d24fc 100644 --- a/src/nativeServices/serviceDoctor.test.ts +++ b/src/nativeServices/serviceDoctor.test.ts @@ -84,7 +84,7 @@ describe("installed native-service mode and definition inspection", () => { expect(inferInstalledNativeServiceMode(new Set(["sessiond"]))).toBe("ambiguous"); }); - it.each(["systemd", "launchd"] as const)("reconstructs an exact installed development input from %s definitions", (kind) => { + it.each(["systemd", "launchd"] as const)("reconstructs POSIX development paths from %s definitions on every host", (kind) => { const plan = developmentPlan(kind); expect(inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan))).toEqual({ ok: true, @@ -124,6 +124,19 @@ describe("installed native-service mode and definition inspection", () => { }); }); + it("interprets installed shell executable paths with POSIX semantics", () => { + const plan = developmentPlan("systemd"); + const definitions = renderedDefinitions(plan).map((definition) => ({ + ...definition, + contents: definition.contents.replace('"/bin/zsh"', '"/bin/not-zsh\\\\zsh"'), + })); + + const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions); + expect(inspection.ok).toBe(false); + if (inspection.ok) throw new Error("Expected the POSIX shell basename inspection to fail"); + expect(inspection.message).toContain("unsupported login shell"); + }); + it("inspects legacy systemd definitions without /usr/bin/env or quoted working directories", () => { const plan = developmentPlan("systemd"); const definitions = renderedDefinitions(plan).map((definition) => ({ diff --git a/src/nativeServices/serviceDoctor.ts b/src/nativeServices/serviceDoctor.ts index 9bc0a1e..c1043da 100644 --- a/src/nativeServices/serviceDoctor.ts +++ b/src/nativeServices/serviceDoctor.ts @@ -1,4 +1,4 @@ -import { basename, join } from "node:path"; +import { posix as posixPath } from "node:path"; import { createDevelopmentNativeServicePlan, nativeServicePrerequisiteNeedsPathAdvice, @@ -135,7 +135,7 @@ export function inspectInstalledDevelopmentServiceInput( shell: first.shell, environment: first.environment, workingDirectory: first.workingDirectory, - packageJsonPath: join(first.workingDirectory, "package.json"), + packageJsonPath: posixPath.join(first.workingDirectory, "package.json"), }; const expectedPlan = createDevelopmentNativeServicePlan(input); for (const definition of parsed.value) { @@ -436,7 +436,7 @@ function parseLaunchdDefinition( } function installedShell(executable: string): InstalledNativeServiceInspection { - const name = basename(executable).replace(/^-/, ""); + const name = posixPath.basename(executable).replace(/^-/, ""); if (name !== "bash" && name !== "zsh" && name !== "fish") { return { ok: false, message: `Installed service definition uses unsupported login shell ${executable}.` }; } diff --git a/src/nativeServices/serviceRendering.test.ts b/src/nativeServices/serviceRendering.test.ts index 617d407..5fe6567 100644 --- a/src/nativeServices/serviceRendering.test.ts +++ b/src/nativeServices/serviceRendering.test.ts @@ -70,7 +70,8 @@ describe("native service rendering", () => { expect(plist).toContain("exec npm run start:sessiond"); expect(plist).toContain("WorkingDirectory\n /checkout with space"); expect(plist).toContain("PI_WEB_CONFIG\n /home/user/config with "quote".json"); - expect(plist).toContain("/logs/sessiond.log"); + expect(plist.match(/\/logs\/sessiond\.log<\/string>/gu)).toHaveLength(2); + expect(plist).not.toContain("\\logs\\sessiond.log"); expect(plist).not.toContain("KeepAlive"); }); diff --git a/src/nativeServices/serviceRendering.ts b/src/nativeServices/serviceRendering.ts index a421f9c..401ce13 100644 --- a/src/nativeServices/serviceRendering.ts +++ b/src/nativeServices/serviceRendering.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { posix as posixPath } from "node:path"; import type { NativeServiceId, NativeServicePlan, @@ -42,7 +42,7 @@ export function renderLaunchdPlist( const keepAlive = service.restart === "on-failure" ? " KeepAlive\n \n SuccessfulExit\n \n \n" : ""; - const logPath = join(logDirectory, service.manager.logName); + const logPath = posixPath.join(logDirectory, service.manager.logName); return ` From 8b0452d545f676f1595af7b0ecfb285daaad5a3d Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Mon, 13 Jul 2026 12:15:18 +0000 Subject: [PATCH 09/12] fix(docker): synchronize dev dependency volume --- docker/Dockerfile.dev | 22 ++++- docker/README.md | 8 +- docker/compose.dev.yml | 4 +- docker/internal/dev/sync-node-modules | 56 +++++++++++ src/server/dockerControlAssets.test.ts | 14 ++- src/server/dockerDevDependencySync.test.ts | 102 +++++++++++++++++++++ 6 files changed, 195 insertions(+), 11 deletions(-) create mode 100755 docker/internal/dev/sync-node-modules create mode 100644 src/server/dockerDevDependencySync.test.ts diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index e21a189..0f989d0 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -36,12 +36,22 @@ WORKDIR /workspace COPY package.json package-lock.json ./ COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs +# Keep an immutable dependency seed outside /workspace, which is hidden by the +# checkout bind mount at runtime. A cached generation is added after custom +# image hooks so it identifies the final dependency tree. RUN npm ci \ - && ln -sf /workspace/node_modules/.bin/pi /usr/local/bin/pi \ + && install -d -m 0755 /opt/pi-web-dev-dependencies \ + && cp package.json package-lock.json /opt/pi-web-dev-dependencies/ \ + && chmod -R a+rwX /workspace/node_modules \ + && mv /workspace/node_modules /opt/pi-web-dev-dependencies/node_modules \ + && ln -s /opt/pi-web-dev-dependencies/node_modules /workspace/node_modules \ + && ln -sf /opt/pi-web-dev-dependencies/node_modules/.bin/pi /usr/local/bin/pi \ && npm cache clean --force \ - && chmod -R a+rwX /workspace/node_modules /data \ + && chmod -R a+rwX /data \ && chmod 0777 /workspace +COPY --chmod=0755 docker/internal/dev/sync-node-modules /usr/local/sbin/pi-web-dev-sync-node-modules + COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins COPY docker/internal/bin/hostexec /usr/local/bin/hostexec @@ -49,6 +59,8 @@ COPY docker/pi-web-docker /usr/local/bin/pi-web-docker RUN chmod 0755 /usr/local/bin/hostexec /usr/local/bin/pi-web-docker COPY docker/custom-image.d/ /tmp/pi-web-custom-image.d/ +# Image hooks use the temporary /workspace/node_modules symlink. Leave an empty +# directory afterward so Compose can mount and populate the dependency volume. RUN bash -euxo pipefail -c '\ shopt -s nullglob; \ for script in /tmp/pi-web-custom-image.d/*.sh; do \ @@ -56,9 +68,15 @@ RUN bash -euxo pipefail -c '\ bash "${script}"; \ done; \ rm -rf /tmp/pi-web-custom-image.d; \ + test -L /workspace/node_modules; \ + rm /workspace/node_modules; \ + install -d -m 0777 /workspace/node_modules; \ zypper clean --all; \ rm -rf /var/cache/zypp/* \ ' +# Cache the generation with the completed seed. Changes to any preceding layer, +# including custom image hooks, rerun this step and refresh the named volume. +RUN node -e 'process.stdout.write(`${require("node:crypto").randomUUID()}\n`)' > /opt/pi-web-dev-dependencies/generation EXPOSE 8504 8505 diff --git a/docker/README.md b/docker/README.md index 9f72d59..ff5bda8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -327,13 +327,9 @@ Use this shared directory to switch between runtime and dev mode, not to run bot For sessions to appear under the same workspace in both modes, use the same project path in PI WEB. On Linux, prefer host-mounted paths such as `/home/core/`, `/srv/`, or `/opt/`. On Mac, prefer paths under `/Users//...`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for the host-mounted path. -When `package-lock.json` changes, rebuild the dev image and recreate the `node_modules` volume so the bind-mounted checkout sees the new dependency tree: +Development startup keeps the persistent `node_modules` volume synchronized with the dependency tree built into the dev image. When `package.json`, `package-lock.json`, the Node image, or another dependency-build input changes, `start` or `update` rebuilds the image and `data-init` refreshes the volume before `sessiond` starts. Manual volume removal is not required. -```bash -./docker/pi-web-docker --dev stop -docker volume rm pi-web-dev_node_modules -./docker/pi-web-docker --dev start -``` +If Compose is invoked directly without rebuilding after a manifest change, `data-init` stops with a mismatch message instead of starting against stale dependencies. Run `./docker/pi-web-docker --dev start` or `./docker/pi-web-docker --dev update` to rebuild and synchronize it. ## Local checkout validation diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index cbf76dc..27cac48 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -56,14 +56,14 @@ services: set -euo pipefail mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent chown -R "${PI_WEB_UID:-1000}:${PI_WEB_GID:-1000}" /data + /usr/local/sbin/pi-web-dev-sync-node-modules user: "0:0" security_opt: - label=disable environment: PI_WEB_UID: ${PI_WEB_UID:-1000} PI_WEB_GID: ${PI_WEB_GID:-1000} - volumes: - - *pi-web-dev-data-volume + volumes: *pi-web-dev-volumes sessiond: build: *pi-web-dev-build diff --git a/docker/internal/dev/sync-node-modules b/docker/internal/dev/sync-node-modules new file mode 100755 index 0000000..51baac5 --- /dev/null +++ b/docker/internal/dev/sync-node-modules @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +export PATH + +log() { + printf '%s\n' "$*" >&2 +} + +die() { + log "pi-web Docker dev dependencies: $*" + exit 1 +} + +workspace_dir=${PI_WEB_DEV_WORKSPACE_DIR:-/workspace} +seed_dir=${PI_WEB_DEV_DEPENDENCY_SEED_DIR:-/opt/pi-web-dev-dependencies} +target_dir=$workspace_dir/node_modules +generation_file=$seed_dir/generation +marker_file=$target_dir/.pi-web-dev-dependency-generation + +# A direct Compose invocation may skip the image rebuild. Fail closed rather +# than copying dependencies for different checkout manifests. +for manifest in package.json package-lock.json; do + source_manifest=$workspace_dir/$manifest + image_manifest=$seed_dir/$manifest + [ -f "$source_manifest" ] || die "checkout is missing $source_manifest" + [ -f "$image_manifest" ] || die "development image is missing $image_manifest" + if ! cmp -s "$source_manifest" "$image_manifest"; then + die "development image dependencies do not match the checkout; run ./docker/pi-web-docker --dev start or update to rebuild the image" + fi +done + +[ -d "$seed_dir/node_modules" ] || die "development image is missing the dependency seed at $seed_dir/node_modules" +[ -s "$generation_file" ] || die "development image is missing its dependency generation at $generation_file" +[ ! -L "$target_dir" ] || die "refusing to synchronize through the node_modules symlink at $target_dir" +mkdir -p "$target_dir" + +expected_generation=$(cat "$generation_file") +current_generation= +if [ -f "$marker_file" ]; then + current_generation=$(cat "$marker_file") +fi + +if [ "$current_generation" = "$expected_generation" ]; then + log "PI WEB Docker dev dependencies are current." + exit 0 +fi + +log "Synchronizing PI WEB Docker dev dependencies from the rebuilt image ..." +# Write the marker only after a complete copy so a failed init retries next time. +find "$target_dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + +cp -a "$seed_dir/node_modules/." "$target_dir/" +printf '%s\n' "$expected_generation" >"$marker_file" +chmod 0666 "$marker_file" +log "PI WEB Docker dev dependencies synchronized." diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts index f356a4d..4502c7a 100644 --- a/src/server/dockerControlAssets.test.ts +++ b/src/server/dockerControlAssets.test.ts @@ -39,20 +39,24 @@ describe("Docker command assets", () => { execUtf8("sh", ["-n", dockerEntrypoint], process.env), execUtf8("sh", ["-n", join(repoRoot, "docker", "install.sh")], process.env), execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "dev", "compose")], process.env), + execUtf8("bash", ["-n", join(repoRoot, "docker", "internal", "dev", "sync-node-modules")], process.env), execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "host-profile.sh")], process.env), ]); }); it("packages the canonical Docker command and internal support assets", async () => { - const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dockerignore] = await Promise.all([ + const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dependencySync, dockerignore] = await Promise.all([ readRepoFile("docker/Dockerfile"), readRepoFile("docker/Dockerfile.dev"), readRepoFile("docker/compose.yml"), readRepoFile("docker/compose.dev.yml"), readRepoFile("docker/install.sh"), readRepoFile("docker/internal/dev/compose"), + readRepoFile("docker/internal/dev/sync-node-modules"), readRepoFile("docker/.dockerignore"), ]); + const customImageHooksIndex = devDockerfile.indexOf("for script in /tmp/pi-web-custom-image.d/*.sh"); + const dependencyGenerationIndex = devDockerfile.indexOf("/opt/pi-web-dev-dependencies/generation"); expect(dockerfile).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker"); expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec"); @@ -62,6 +66,12 @@ describe("Docker command assets", () => { expect(dockerfile).not.toContain("@earendil-works/pi-coding-agent@"); expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker"); expect(devDockerfile).toContain("COPY docker/internal/bin/hostexec /usr/local/bin/hostexec"); + expect(devDockerfile).toContain("COPY --chmod=0755 docker/internal/dev/sync-node-modules /usr/local/sbin/pi-web-dev-sync-node-modules"); + expect(devDockerfile).toContain("/opt/pi-web-dev-dependencies/node_modules"); + // Hooks can mutate the dependency seed, so its cache generation must be finalized afterward. + expect(customImageHooksIndex).toBeGreaterThanOrEqual(0); + expect(dependencyGenerationIndex).toBeGreaterThan(customImageHooksIndex); + expect(dependencySync).toContain(".pi-web-dev-dependency-generation"); expect(dockerignore).toContain("!pi-web-docker"); expect(dockerignore).toContain("!internal/bin/hostexec"); expect(installer).toContain("write_asset pi-web-docker 0755"); @@ -83,6 +93,8 @@ describe("Docker command assets", () => { expect(devCompose).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT: ${PI_WEB_DOCKER_DEV_REPO_ROOT:?set by docker/pi-web-docker --dev}"); expect(devCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_DEV_IMAGE:-pi-web:dev}"); expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}"); + expect(devCompose).toContain("/usr/local/sbin/pi-web-dev-sync-node-modules"); + expect(devCompose.match(/volumes: \*pi-web-dev-volumes/g)).toHaveLength(3); }); dockerCommandIt("fetches remote installer assets without clobbering the write target", async () => { diff --git a/src/server/dockerDevDependencySync.test.ts b/src/server/dockerDevDependencySync.test.ts new file mode 100644 index 0000000..d5ef6f2 --- /dev/null +++ b/src/server/dockerDevDependencySync.test.ts @@ -0,0 +1,102 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const syncScript = join(repoRoot, "docker", "internal", "dev", "sync-node-modules"); +const dockerSyncIt = it.skipIf(process.platform === "win32"); + +let tempDir = ""; + +interface SyncFixture { + workspaceDir: string; + seedDir: string; + targetDir: string; +} + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-dependencies-test-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("Docker development dependency synchronization", () => { + dockerSyncIt("replaces a stale dependency tree once per image generation", async () => { + const fixture = await createSyncFixture(); + + const first = await runSync(fixture); + + expect(first.exitCode).toBe(0); + expect(first.stderr).toContain("Synchronizing PI WEB Docker dev dependencies"); + expect(await readFile(join(fixture.targetDir, "fresh", "version.txt"), "utf8")).toBe("0.80.6\n"); + expect(await readlink(join(fixture.targetDir, ".bin", "fresh"))).toBe("../fresh/version.txt"); + expect(await readFile(join(fixture.targetDir, ".pi-web-dev-dependency-generation"), "utf8")).toBe("image-generation-2\n"); + await expect(readFile(join(fixture.targetDir, "stale.txt"), "utf8")).rejects.toThrow(); + + await writeFile(join(fixture.targetDir, "keep-on-current-generation.txt"), "kept\n", "utf8"); + const second = await runSync(fixture); + + expect(second.exitCode).toBe(0); + expect(second.stderr).toContain("dependencies are current"); + expect(await readFile(join(fixture.targetDir, "keep-on-current-generation.txt"), "utf8")).toBe("kept\n"); + }); + + dockerSyncIt("fails without changing the volume when the image manifests are stale", async () => { + const fixture = await createSyncFixture(); + await writeFile(join(fixture.workspaceDir, "package-lock.json"), '{"lockfileVersion":3,"changed":true}\n', "utf8"); + + const result = await runSync(fixture); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("development image dependencies do not match the checkout"); + expect(await readFile(join(fixture.targetDir, "stale.txt"), "utf8")).toBe("stale\n"); + }); +}); + +async function createSyncFixture(): Promise { + const workspaceDir = join(tempDir, "workspace"); + const seedDir = join(tempDir, "seed"); + const targetDir = join(workspaceDir, "node_modules"); + const packageJson = '{"name":"dependency-sync-fixture","private":true}\n'; + const packageLock = '{"name":"dependency-sync-fixture","lockfileVersion":3}\n'; + + await Promise.all([ + mkdir(join(seedDir, "node_modules", "fresh"), { recursive: true }), + mkdir(join(seedDir, "node_modules", ".bin"), { recursive: true }), + mkdir(targetDir, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(workspaceDir, "package.json"), packageJson, "utf8"), + writeFile(join(workspaceDir, "package-lock.json"), packageLock, "utf8"), + writeFile(join(seedDir, "package.json"), packageJson, "utf8"), + writeFile(join(seedDir, "package-lock.json"), packageLock, "utf8"), + writeFile(join(seedDir, "generation"), "image-generation-2\n", "utf8"), + writeFile(join(seedDir, "node_modules", "fresh", "version.txt"), "0.80.6\n", "utf8"), + writeFile(join(targetDir, "stale.txt"), "stale\n", "utf8"), + writeFile(join(targetDir, ".pi-web-dev-dependency-generation"), "image-generation-1\n", "utf8"), + ]); + await symlink("../fresh/version.txt", join(seedDir, "node_modules", ".bin", "fresh")); + + return { workspaceDir, seedDir, targetDir }; +} + +function runSync(fixture: SyncFixture): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolvePromise) => { + execFile("bash", [syncScript], { + encoding: "utf8", + env: { + ...process.env, + PI_WEB_DEV_WORKSPACE_DIR: fixture.workspaceDir, + PI_WEB_DEV_DEPENDENCY_SEED_DIR: fixture.seedDir, + }, + }, (error, stdout, stderr) => { + const exitCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" ? error.code : 0; + resolvePromise({ stdout, stderr, exitCode }); + }); + }); +} From 0981a2dc8fd98907be7ff6d7e248a876d30c6f81 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 14:15:59 +0200 Subject: [PATCH 10/12] fix(cli): use POSIX launchd probe paths --- src/nativeServices/serviceProbe.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/nativeServices/serviceProbe.ts b/src/nativeServices/serviceProbe.ts index 3ff8b83..8036f8b 100644 --- a/src/nativeServices/serviceProbe.ts +++ b/src/nativeServices/serviceProbe.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir, userInfo } from "node:os"; -import { join } from "node:path"; +import { posix as posixPath } from "node:path"; import { performance } from "node:perf_hooks"; import { randomUUID } from "node:crypto"; import type { @@ -128,13 +128,13 @@ export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProb try { directory = await this.dependencies.fileSystem.createTemporaryDirectory( - join(tmpdir(), "pi-web-launchd-probe-"), + posixPath.join(tmpdir(), "pi-web-launchd-probe-"), ); - const plistPath = join(directory, "probe.plist"); - const stdoutPath = join(directory, "stdout.log"); - const stderrPath = join(directory, "stderr.log"); - const pendingResultPath = join(directory, "result.pending"); - const resultPath = join(directory, "result.log"); + const plistPath = posixPath.join(directory, "probe.plist"); + const stdoutPath = posixPath.join(directory, "stdout.log"); + const stderrPath = posixPath.join(directory, "stderr.log"); + const pendingResultPath = posixPath.join(directory, "result.pending"); + const resultPath = posixPath.join(directory, "result.log"); const command = prerequisiteProbeCommand( request.shell.name, request.prerequisites, From c217c4a41708f1e8f535dfcf82b5e607ac645f80 Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Mon, 13 Jul 2026 14:52:46 +0000 Subject: [PATCH 11/12] fix(docker): refuse unsafe development updates --- docker/README.md | 6 +- docker/pi-web-docker | 61 ++++++++++++++++ src/server/dockerControlAssets.test.ts | 99 +++++++++++++++++++++++++- 3 files changed, 164 insertions(+), 2 deletions(-) diff --git a/docker/README.md b/docker/README.md index ff5bda8..72bdafc 100644 --- a/docker/README.md +++ b/docker/README.md @@ -73,7 +73,7 @@ From a production/runtime install directory, run `./pi-web-docker `. Fr | `restart` | `./pi-web-docker restart` | `./docker/pi-web-docker --dev restart` | Restarts `web` and `sessiond`. | | `restart-web` | `./pi-web-docker restart-web` | `./docker/pi-web-docker --dev restart-web` | Restarts only the web/API service. | | `restart-sessiond` | `./pi-web-docker restart-sessiond` | `./docker/pi-web-docker --dev restart-sessiond` | Restarts the session daemon; active agent runtimes may stop in that Docker stack. | -| `update` | `./pi-web-docker update` | `./docker/pi-web-docker --dev update` | Rebuilds/recreates the stack. Runtime host updates rerun the installer to refresh Docker assets first. | +| `update` | `./pi-web-docker update` | `./docker/pi-web-docker --dev update` | Rebuilds/recreates the stack. Runtime host updates rerun the installer to refresh Docker assets first. Development updates require a clean Git checkout with no Git operation in progress. | | `status` | `./pi-web-docker status` | `./docker/pi-web-docker --dev status` | Shows Docker Compose service status. | | `logs` | `./pi-web-docker logs [web\|sessiond]` | `./docker/pi-web-docker --dev logs [web\|sessiond\|data-init]` | Follows logs; omitting a target follows all services. | | `shell` | `./pi-web-docker shell [web\|sessiond]` | `./docker/pi-web-docker --dev shell [web\|sessiond]` | Opens Bash in `web` by default. | @@ -281,6 +281,10 @@ PI_WEB_DEV_BIND_ADDR=0.0.0.0 \ ./docker/pi-web-docker --dev start ``` +Development `update` is intentionally fail-closed. Before starting a Docker helper or build, it requires this repository to be a clean Git checkout, including no staged, modified, or untracked files, and no merge, rebase, cherry-pick, revert, sequenced operation, or bisect in progress. It never stashes, removes, or rewrites developer work; resolve, commit, stash, or remove that work explicitly and rerun the update. This guard applies only to `update`: `start` and restart commands remain available for normal development against an intentionally dirty checkout. + +The Docker command rebuilds the current checkout; it does not merge branches or resolve source updates. Perform any Git integration separately, then run the guarded Docker update after the checkout is clean. + You can run the dev stack in the background with: ```bash diff --git a/docker/pi-web-docker b/docker/pi-web-docker index 0880d9d..c0d8e64 100755 --- a/docker/pi-web-docker +++ b/docker/pi-web-docker @@ -25,6 +25,7 @@ Commands: restart-web Restart only the web service restart-sessiond Restart only the session daemon update Rebuild/update and recreate the Docker stack + (development mode requires a clean Git checkout) status Show Docker Compose service status logs [web|sessiond|data-init] Follow Docker Compose logs @@ -229,6 +230,60 @@ enforce_dev_root_safety() { [ "$uid" != 0 ] || die "refusing to run Docker development mode as root; retry with --allow-root if this is intentional" } +dev_git_operation() { + git_dir=$1 + if [ -f "$git_dir/MERGE_HEAD" ]; then + printf '%s\n' merge + elif [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ] || [ -f "$git_dir/REBASE_HEAD" ]; then + printf '%s\n' rebase + elif [ -f "$git_dir/CHERRY_PICK_HEAD" ]; then + printf '%s\n' cherry-pick + elif [ -f "$git_dir/REVERT_HEAD" ]; then + printf '%s\n' revert + elif [ -d "$git_dir/sequencer" ]; then + printf '%s\n' sequenced-operation + elif [ -f "$git_dir/BISECT_LOG" ]; then + printf '%s\n' bisect + else + return 1 + fi +} + +require_clean_dev_update_checkout() { + [ "$(docker_mode)" = dev ] || return 0 + root=$(dev_root) + require_command git + + git_root=$(git -C "$root" rev-parse --show-toplevel 2>/dev/null) \ + || die "Docker development update requires a Git checkout at $root" + git_root=$(absolute_existing_dir "$git_root") \ + || die "could not resolve Git checkout root: $git_root" + [ "$git_root" = "$root" ] \ + || die "Docker development root $root must be the Git checkout root ($git_root)" + git_dir=$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null) \ + || die "could not resolve Git metadata for $root" + + operation=$(dev_git_operation "$git_dir" 2>/dev/null || true) + if [ -n "$operation" ]; then + log "pi-web-docker: refusing to update the Docker development stack while a Git $operation is in progress: $root" + checkout_status=$(git -C "$root" status --porcelain=v1 --untracked-files=all 2>/dev/null || true) + if [ -n "$checkout_status" ]; then + log "Checkout status:" + printf '%s\n' "$checkout_status" >&2 + fi + die "resolve or abort the Git $operation before rerunning pi-web-docker --dev update" + fi + + checkout_status=$(git -C "$root" status --porcelain=v1 --untracked-files=all) \ + || die "could not inspect Git checkout status at $root" + if [ -n "$checkout_status" ]; then + log "pi-web-docker: refusing to update the Docker development stack because the checkout has uncommitted changes: $root" + log "Checkout status:" + printf '%s\n' "$checkout_status" >&2 + die "commit, stash, or remove these changes before rerunning pi-web-docker --dev update; no files were changed" + fi +} + enforce_container_mode_match() { is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || return 0 runtime_mode=${PI_WEB_DOCKER_MODE:-} @@ -401,6 +456,7 @@ run_runtime_host_update() { run_update() { assert_no_args update "$@" + require_clean_dev_update_checkout case "$(docker_mode)" in runtime) if ! is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then @@ -729,6 +785,11 @@ run_restart_or_update() { shift assert_no_args "$action" "$@" if is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then + # Fail before scheduling a helper, then recheck inside the helper in + # run_update so a checkout change cannot race the detached operation. + if [ "$action" = update ]; then + require_clean_dev_update_checkout + fi start_detached_helper "$action" return 0 fi diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts index 4502c7a..12bb43c 100644 --- a/src/server/dockerControlAssets.test.ts +++ b/src/server/dockerControlAssets.test.ts @@ -259,6 +259,84 @@ describe("Docker command assets", () => { expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n"); }); + dockerCommandIt("refuses development updates when the checkout has uncommitted files", async () => { + const helperLog = join(tempDir, "dev-helper.log"); + const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog); + const fakeDocker = await installFakeDocker(); + await installFakeId(fakeDocker.binDir, 1234, 2345); + await writeFile(join(devRoot, "staged.txt"), "changed\n", "utf8"); + await execUtf8("git", ["-C", devRoot, "add", "staged.txt"], cleanProcessEnv()); + await writeFile(join(devRoot, "modified.txt"), "changed\n", "utf8"); + await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8"); + + const result = await runDockerCommandAllowFailure( + ["--dev", "update"], + devHostEnv(fakeDocker, devRoot, join(tempDir, "home")), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("refusing to update the Docker development stack because the checkout has uncommitted changes"); + expect(result.stderr).toContain("staged.txt"); + expect(result.stderr).toContain("modified.txt"); + expect(result.stderr).toContain("?? untracked.txt"); + expect(result.stderr).toContain("commit, stash, or remove these changes"); + await expect(readFile(helperLog, "utf8")).rejects.toThrow(); + }); + + dockerCommandIt("refuses dirty development updates before scheduling a detached helper", async () => { + const helperLog = join(tempDir, "dev-helper.log"); + const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog); + const fakeDocker = await installFakeDocker(); + await installFakeId(fakeDocker.binDir, 1234, 2345); + await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8"); + + const result = await runDockerCommandAllowFailure(["--dev", "update"], devRuntimeEnv(fakeDocker, devRoot)); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("checkout has uncommitted changes"); + expect(result.stdout).not.toContain("Started detached PI WEB Docker helper"); + await expect(readFile(fakeDocker.logPath, "utf8")).rejects.toThrow(); + await expect(readFile(helperLog, "utf8")).rejects.toThrow(); + }); + + dockerCommandIt("refuses development updates while a Git operation is in progress", async () => { + const helperLog = join(tempDir, "dev-helper.log"); + const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog); + const fakeDocker = await installFakeDocker(); + await installFakeId(fakeDocker.binDir, 1234, 2345); + const head = (await execUtf8("git", ["-C", devRoot, "rev-parse", "HEAD"], cleanProcessEnv())).stdout.trim(); + await writeFile(join(devRoot, ".git", "MERGE_HEAD"), `${head}\n`, "utf8"); + + const result = await runDockerCommandAllowFailure( + ["--dev", "update"], + devHostEnv(fakeDocker, devRoot, join(tempDir, "home")), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("while a Git merge is in progress"); + expect(result.stderr).toContain("resolve or abort the Git merge"); + await expect(readFile(helperLog, "utf8")).rejects.toThrow(); + }); + + dockerCommandIt("allows clean development updates and dirty development starts", async () => { + const helperLog = join(tempDir, "dev-helper.log"); + const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog); + const fakeDocker = await installFakeDocker(); + await installFakeId(fakeDocker.binDir, 1234, 2345); + const env = devHostEnv(fakeDocker, devRoot, join(tempDir, "home")); + + await runDockerCommand(["--dev", "update"], env); + await writeFile(join(devRoot, "in-progress-work.txt"), "dirty by design\n", "utf8"); + await runDockerCommand(["--dev", "start"], env); + + expect(await readFile(helperLog, "utf8")).toBe([ + "allow=0 args=build --pull", + "allow=0 args=up -d --force-recreate --remove-orphans", + "allow=0 args=up -d --build", + "", + ].join("\n")); + }); + dockerCommandIt("starts development detached helpers as the generated dev user", async () => { const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 }); const fakeDocker = await installFakeDocker(); @@ -443,12 +521,31 @@ async function createDevRepoFixtureWithFakeHelper(logPath: string): Promise${shellSingleQuote(logPath)} +printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >>${shellSingleQuote(logPath)} `, "utf8"); await chmod(helperPath, 0o755); return devRoot; } +async function createCleanDevGitRepoWithFakeHelper(logPath: string): Promise { + const devRoot = await createDevRepoFixtureWithFakeHelper(logPath); + await Promise.all([ + writeFile(join(devRoot, "staged.txt"), "clean\n", "utf8"), + writeFile(join(devRoot, "modified.txt"), "clean\n", "utf8"), + ]); + const env = cleanProcessEnv(); + await execUtf8("git", ["init", "--quiet", devRoot], env); + await execUtf8("git", ["-C", devRoot, "add", "."], env); + await execUtf8("git", [ + "-C", devRoot, + "-c", "user.name=PI WEB Test", + "-c", "user.email=pi-web-test@example.invalid", + "-c", "core.hooksPath=/dev/null", + "commit", "--quiet", "--no-gpg-sign", "-m", "test fixture", + ], env); + return devRoot; +} + async function createDevGeneratedEnv(ids: { uid: number; gid: number; dockerGid: number }): Promise { const devRoot = join(tempDir, "dev-runtime"); await mkdir(join(devRoot, ".pi-web"), { recursive: true }); From 6174cf381c2494393fb0f8f31e7da1527cbc342f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 19:08:51 +0200 Subject: [PATCH 12/12] docs: keep README quick start concise --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 5547cea..fbbc619 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,6 @@ pi-web version pi-web uninstall ``` -`pi-web install` validates the safely verifiable requirements of the exact production or development service plan inside the native user-service manager before changing config or replacing services; arbitrary configured command overrides are preserved but not executed by preflight. `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