feat: add pi-web version reporting

This commit is contained in:
Federico Jaramillo Martinez
2026-05-28 21:33:14 +02:00
parent 4043ce7eca
commit 50906617a0
18 changed files with 389 additions and 70 deletions
+2 -1
View File
@@ -14,7 +14,7 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { getPiWebStatus } from "./piWebStatus.js";
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
export interface AppDependencies {
projects?: ProjectService;
@@ -41,6 +41,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
});
app.get("/api/pi-web/status", async () => getPiWebStatus());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
app.get("/api/projects", async () => projects.list());
+27 -2
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { comparePackageVersions, getPiWebStatus } from "./piWebStatus.js";
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
@@ -17,6 +17,31 @@ describe("PI WEB status", () => {
expect(comparePackageVersions("1.202605.7", "1.202605.8")).toBeLessThan(0);
});
it("returns installed and running version components without release metadata", async () => {
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
version: {
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
installedVersion: "1.202605.8",
stale: true,
available: true,
},
}),
});
const status = await getPiWebVersionStatus(daemon);
expect(status.packageName).toBe("@jmfederico/pi-web");
expect(status.components.web.component).toBe("web");
expect(status.components.sessiond.runtimeVersion).toBe("1.202605.7");
expect(status).not.toHaveProperty("release");
});
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
const daemon = new SessionDaemonClient();
+19 -52
View File
@@ -4,8 +4,9 @@ import { readFile, realpath, stat } from "node:fs/promises";
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 type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse } from "../shared/apiTypes.js";
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { parsePiWebComponentStatus } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
@@ -47,20 +48,27 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent):
};
}
export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
const web = await getPiWebComponentStatus("web");
const [installed, sessiond] = await Promise.all([
readInstalledPackageInfo(),
export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
const [web, sessiond] = await Promise.all([
getPiWebComponentStatus("web"),
getSessiondComponentStatus(daemon),
]);
const release = await getLatestReleaseStatus(installed?.version ?? web.runtimeVersion ?? DEFAULT_VERSION);
return {
packageName: PI_WEB_PACKAGE_NAME,
generatedAt: new Date().toISOString(),
components: { web, sessiond },
};
}
export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
const versionStatus = await getPiWebVersionStatus(daemon);
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
const components = { web, sessiond };
const commands = commandsFor(web.installation ?? sessiond.installation);
const messages = buildMessages(components, release, commands);
return {
packageName: PI_WEB_PACKAGE_NAME,
generatedAt: new Date().toISOString(),
components,
...versionStatus,
release,
commands,
messages,
@@ -179,54 +187,13 @@ async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise<
}
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
const version = isRecord(parsed) ? parsed["version"] : undefined;
const component = parseComponentStatus(version);
const component = parsePiWebComponentStatus(version);
return component ?? unavailableSessiond("health response did not include version information");
} catch (error) {
return unavailableSessiond(error instanceof Error ? error.message : String(error));
}
}
function parseComponentStatus(value: unknown): PiWebComponentStatus | undefined {
if (!isRecord(value)) return undefined;
const component = value["component"];
const label = value["label"];
const runtimeVersion = value["runtimeVersion"];
const installedVersion = value["installedVersion"];
const stale = value["stale"];
const available = value["available"];
const error = value["error"];
const installation = parseInstallationInfo(value["installation"]);
if (component !== "web" && component !== "sessiond") return undefined;
if (typeof label !== "string" || typeof stale !== "boolean" || typeof available !== "boolean") return undefined;
return {
component,
label,
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
...(typeof installedVersion === "string" ? { installedVersion } : {}),
stale,
available,
...(installation === undefined ? {} : { installation }),
...(typeof error === "string" ? { error } : {}),
};
}
function parseInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
if (!isRecord(value)) return undefined;
const kind = value["kind"];
const path = value["path"];
const source = value["source"];
const scope = value["scope"];
const npmRoot = value["npmRoot"];
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined;
return {
kind,
...(typeof path === "string" ? { path } : {}),
...(typeof source === "string" ? { source } : {}),
...(scope === "user" || scope === "project" ? { scope } : {}),
...(typeof npmRoot === "string" ? { npmRoot } : {}),
};
}
function unavailableSessiond(error: string): PiWebComponentStatus {
return {
component: "sessiond",
+1 -1
View File
@@ -10,7 +10,7 @@ import { AuthService } from "./sessions/authService.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js";
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { sessiondSocketPath } from "./sessiond/config.js";
import { sessiondSocketPath } from "../sessiond/config.js";
import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebComponentStatus } from "./piWebStatus.js";
-10
View File
@@ -1,10 +0,0 @@
import { join } from "node:path";
import { piWebDataDir } from "../../config.js";
export function sessiondSocketPath(): string {
return process.env["PI_WEB_SESSIOND_SOCKET"] ?? join(piWebDataDir(), "sessiond.sock");
}
export function sessiondHttpUrl(): string | undefined {
return process.env["PI_WEB_SESSIOND_URL"];
}
@@ -1,68 +0,0 @@
import http from "node:http";
import { WebSocket } from "ws";
import { sessiondHttpUrl, sessiondSocketPath } from "./config.js";
export class SessionDaemonClient {
private readonly baseUrl = sessiondHttpUrl();
private readonly socketPath = sessiondSocketPath();
async request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
const payload = body === undefined ? undefined : JSON.stringify(body);
if (this.baseUrl !== undefined && this.baseUrl !== "") return this.requestUrl(method, path, payload);
return this.requestSocket(method, path, payload);
}
connectWebSocket(path: string): WebSocket {
if (this.baseUrl !== undefined && this.baseUrl !== "") {
const url = new URL(path, this.baseUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return new WebSocket(url);
}
return new WebSocket(`ws+unix:${this.socketPath}:${path}`);
}
private async requestUrl(method: string, path: string, payload?: string) {
const init: RequestInit = { method };
if (payload !== undefined && payload !== "") {
init.headers = { "content-type": "application/json" };
init.body = payload;
}
const response = await fetch(new URL(path, this.baseUrl), init);
return {
statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()),
body: await response.text(),
};
}
private requestSocket(method: string, path: string, payload?: string): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
return new Promise((resolve, reject) => {
const request = http.request(
{
socketPath: this.socketPath,
path,
method,
headers: payload !== undefined && payload !== ""
? { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }
: undefined,
},
(response) => {
const chunks: Uint8Array[] = [];
response.on("data", (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on("end", () => {
resolve({
statusCode: response.statusCode ?? 500,
headers: Object.fromEntries(Object.entries(response.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value ?? ""])),
body: Buffer.concat(chunks).toString("utf8"),
});
});
},
);
request.on("error", reject);
if (payload !== undefined && payload !== "") request.write(payload);
request.end();
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import { WebSocket, type RawData } from "ws";
import { SessionDaemonClient } from "./sessionDaemonClient.js";
import { SessionDaemonClient } from "../../sessiond/sessionDaemonClient.js";
export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void {
const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { ProjectService } from "./projects/projectService.js";
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js";
import { terminalSizeQuery } from "./terminals/terminalSize.js";