Archived
feat: add pi-web version reporting
This commit is contained in:
+2
-1
@@ -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());
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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,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";
|
||||
|
||||
Reference in New Issue
Block a user