Useful commands
@@ -204,6 +206,7 @@
$ pi-web logs
$ pi-web restart
$ pi-web doctor
+
$ pi-web version
$ pi-web install --dev
diff --git a/extensions/pi-web.ts b/extensions/pi-web.ts
index baee6c5..d333c46 100644
--- a/extensions/pi-web.ts
+++ b/extensions/pi-web.ts
@@ -18,6 +18,7 @@ const subcommands = [
"start",
"stop",
"doctor",
+ "version",
"uninstall",
"open",
"help",
@@ -88,7 +89,7 @@ async function boundedLogs(): Promise<{ code: number; output: string }> {
export default function piWebExtension(pi: ExtensionAPI): void {
pi.registerCommand("pi-web", {
- description: "Manage PI WEB services: install, status, logs, restart, start, stop, doctor, open",
+ description: "Manage PI WEB services: install, status, logs, restart, start, stop, doctor, version, open",
getArgumentCompletions(prefix: string): { value: string; label: string }[] | null {
const [first = ""] = parseArgs(prefix);
const items = subcommands
diff --git a/src/cli.ts b/src/cli.ts
index d10d45d..745a422 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -6,6 +6,7 @@ import { homedir, userInfo } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
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";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
@@ -885,6 +886,10 @@ function commandCheck(command: string): string {
return `command -v ${command}`;
}
+function commandWithVersionCheck(command: string): string {
+ return `${commandCheck(command)} && (${command} --version 2>&1 || true)`;
+}
+
function nodeVersionCheck(): string {
return [
commandCheck("node"),
@@ -898,21 +903,21 @@ function doctorChecks(): Check[] {
if (backend === undefined) {
return [
[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
- [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"))],
- [`${shell} can find pi`, serviceShellCommand(commandCheck("pi"))],
+ [`${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(commandCheck("npm"))],
- [`${shell} can find pi`, serviceShellCommand(commandCheck("pi"))],
+ [`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
+ [`${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(commandCheck("pi"))]);
+ checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandWithVersionCheck("pi"))]);
}
return checks;
}
@@ -952,7 +957,7 @@ function printPathSetupAdvice(): void {
}
}
-function doctor(): void {
+async function doctor(): Promise
{
const backend = currentServiceBackend();
console.log(`Platform: ${platformLabel()}`);
console.log(`Service backend: ${backend?.label ?? "manual run only"}`);
@@ -960,6 +965,9 @@ function doctor(): void {
if (backend === undefined) {
console.log(`- Native user service checks skipped on ${platformLabel()}`);
}
+ console.log("");
+ await printPiWebVersionReport();
+ console.log("\nDoctor checks:");
const ok = runChecks(doctorChecks());
const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
@@ -1011,6 +1019,7 @@ Usage:
pi-web uninstall
pi-web start|stop|restart|status|logs
pi-web doctor
+ pi-web version
Recommended install:
npm install -g @jmfederico/pi-web
@@ -1027,7 +1036,9 @@ async function main(): Promise {
else if (command === "uninstall") await uninstall();
else if (command === "start" || command === "stop" || command === "restart" || command === "status") serviceAction(command);
else if (command === "logs") logs();
- else if (command === "doctor") doctor();
+ else if (command === "doctor") await doctor();
+ else if (command === "version") await printPiWebVersionReport();
+ else if (command === "--version" || command === "-v") console.log(packageVersion());
else if (command === "help" || command === "--help" || command === "-h") help();
else throw new Error(`Unknown command: ${command}`);
}
diff --git a/src/piWebVersionReport.ts b/src/piWebVersionReport.ts
new file mode 100644
index 0000000..d3567ca
--- /dev/null
+++ b/src/piWebVersionReport.ts
@@ -0,0 +1,239 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { effectivePiWebConfig } from "./config.js";
+import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
+import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebVersionResponse } from "./shared/apiTypes.js";
+import { parsePiWebComponentStatus, parsePiWebVersionResponse } from "./shared/piWebStatusParsing.js";
+
+const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
+const PI_WEB_VERSION_TIMEOUT_MS = 2000;
+const PI_WEB_VERSION_ENDPOINT_PATH = "/api/pi-web/version";
+const PI_WEB_STATUS_ENDPOINT_PATH = "/api/pi-web/status";
+const DEFAULT_PACKAGE_VERSION = "0.0.0-dev";
+
+interface PackageInfo {
+ name: string;
+ version: string;
+ path: string;
+}
+
+interface RunningVersionInfo {
+ generatedAt?: string;
+ web?: PiWebComponentStatus;
+ sessiond?: PiWebComponentStatus;
+ webError?: string;
+ sessiondError?: string;
+}
+
+export function packageVersion(): string {
+ return readPackageInfo()?.version ?? DEFAULT_PACKAGE_VERSION;
+}
+
+export async function printPiWebVersionReport(): Promise {
+ console.log("PI WEB version");
+ printInstalledPackageVersions();
+ printRunningVersionInfo(await collectRunningVersionInfo());
+}
+
+function packageRootPath(): string {
+ return dirname(dirname(fileURLToPath(import.meta.url)));
+}
+
+function packageJsonPath(): string {
+ return join(packageRootPath(), "package.json");
+}
+
+function readPackageInfo(): PackageInfo | undefined {
+ const path = packageJsonPath();
+ try {
+ return parsePackageInfo(JSON.parse(readFileSync(path, "utf8")), path);
+ } catch {
+ return undefined;
+ }
+}
+
+function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined {
+ if (!isRecord(value)) return undefined;
+ const name = value["name"];
+ const version = value["version"];
+ if (typeof name !== "string" || name === "" || typeof version !== "string" || version === "") return undefined;
+ return { name, version, path };
+}
+
+function webVersionEndpoint(): { endpoint?: string; error?: string } {
+ try {
+ const { config } = effectivePiWebConfig();
+ const host = httpClientHost(config.host);
+ const port = config.port ?? 8504;
+ return { endpoint: `http://${urlHost(host)}:${String(port)}${PI_WEB_VERSION_ENDPOINT_PATH}` };
+ } catch (error) {
+ return { error: `could not read PI WEB config: ${errorMessage(error)}` };
+ }
+}
+
+function httpClientHost(configuredHost: string | undefined): string {
+ const host = configuredHost === undefined || configuredHost === "" ? "127.0.0.1" : configuredHost;
+ if (host === "0.0.0.0" || host === "::" || host === "[::]") return "127.0.0.1";
+ return host;
+}
+
+function urlHost(host: string): string {
+ if (host.startsWith("[") || !host.includes(":")) return host;
+ return `[${host}]`;
+}
+
+function statusEndpointFor(versionEndpoint: string): string {
+ if (!versionEndpoint.endsWith(PI_WEB_VERSION_ENDPOINT_PATH)) return versionEndpoint;
+ return `${versionEndpoint.slice(0, -PI_WEB_VERSION_ENDPOINT_PATH.length)}${PI_WEB_STATUS_ENDPOINT_PATH}`;
+}
+
+async function fetchPiWebVersionResponse(endpoint: string): Promise {
+ const response = await fetch(endpoint, {
+ headers: { accept: "application/json" },
+ signal: AbortSignal.timeout(PI_WEB_VERSION_TIMEOUT_MS),
+ });
+ if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
+ const parsed: unknown = await response.json();
+ const status = parsePiWebVersionResponse(parsed);
+ if (status === undefined) throw new Error("response did not include PI WEB version information");
+ return status;
+}
+
+async function collectRunningVersionInfo(): Promise {
+ const endpoint = webVersionEndpoint();
+ if (endpoint.endpoint !== undefined) {
+ try {
+ const status = await fetchPiWebVersionResponse(endpoint.endpoint);
+ return { generatedAt: status.generatedAt, web: status.components.web, sessiond: status.components.sessiond };
+ } catch (error) {
+ let webError = `${endpoint.endpoint}: ${errorMessage(error)}`;
+ const statusEndpoint = statusEndpointFor(endpoint.endpoint);
+ if (statusEndpoint !== endpoint.endpoint && isHttpNotFound(error)) {
+ try {
+ const status = await fetchPiWebVersionResponse(statusEndpoint);
+ return { generatedAt: status.generatedAt, web: status.components.web, sessiond: status.components.sessiond };
+ } catch (statusError) {
+ webError = `${webError}; ${statusEndpoint}: ${errorMessage(statusError)}`;
+ }
+ }
+ return runningVersionInfoWithSessiondFallback({ webError });
+ }
+ }
+
+ return runningVersionInfoWithSessiondFallback({ webError: endpoint.error ?? "web/API status endpoint unavailable" });
+}
+
+async function runningVersionInfoWithSessiondFallback(base: { webError: string }): Promise {
+ const sessiond = await collectRunningSessiondInfo();
+ return {
+ webError: base.webError,
+ ...(sessiond.component === undefined ? {} : { sessiond: sessiond.component }),
+ ...(sessiond.error === undefined ? {} : { sessiondError: sessiond.error }),
+ };
+}
+
+async function collectRunningSessiondInfo(): Promise<{ component?: PiWebComponentStatus; error?: string }> {
+ try {
+ const response = await withTimeout(
+ new SessionDaemonClient().request("GET", "/health"),
+ PI_WEB_VERSION_TIMEOUT_MS,
+ "session daemon health check timed out",
+ );
+ if (response.statusCode < 200 || response.statusCode >= 300) throw new Error(`HTTP ${String(response.statusCode)}`);
+ const parsed: unknown = response.body === "" ? undefined : JSON.parse(response.body);
+ const version = isRecord(parsed) ? parsed["version"] : undefined;
+ const component = parsePiWebComponentStatus(version);
+ if (component === undefined) throw new Error("health response did not include version information");
+ return { component };
+ } catch (error) {
+ return { error: errorMessage(error) };
+ }
+}
+
+async function withTimeout(promise: Promise, timeoutMs: number, timeoutMessage: string): Promise {
+ let timeout: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ timeout = setTimeout(() => {
+ reject(new Error(timeoutMessage));
+ }, timeoutMs);
+ }),
+ ]);
+ } finally {
+ if (timeout !== undefined) clearTimeout(timeout);
+ }
+}
+
+function isHttpNotFound(error: unknown): boolean {
+ return error instanceof Error && error.message === "HTTP 404";
+}
+
+function printInstalledPackageVersions(): void {
+ const info = readPackageInfo();
+ console.log("Installed packages:");
+ if (info === undefined) {
+ console.log(`? ${PI_WEB_PACKAGE_NAME}: unknown`);
+ console.log(` missing package metadata: ${packageJsonPath()}`);
+ return;
+ }
+ console.log(`✓ ${info.name}: ${info.version}`);
+ console.log(` ${info.path}`);
+}
+
+function printRunningVersionInfo(info: RunningVersionInfo): void {
+ console.log("Running services:");
+ if (info.web === undefined) printUnavailableComponent("Web/UI", info.webError);
+ else printComponentVersion(info.web);
+ if (info.sessiond === undefined) printUnavailableComponent("Session daemon", info.sessiondError);
+ else printComponentVersion(info.sessiond);
+ if (info.generatedAt !== undefined) console.log(` reported by web/API at ${info.generatedAt}`);
+}
+
+function printComponentVersion(component: PiWebComponentStatus): void {
+ const icon = component.available ? component.stale ? "!" : "✓" : "?";
+ const status = !component.available ? "unavailable" : component.stale ? "restart needed" : "current";
+ console.log(`${icon} ${component.label}: ${status}`);
+ if (component.available || component.runtimeVersion !== undefined || component.installedVersion !== undefined) {
+ console.log(` running: ${formatVersion(component.runtimeVersion)}; installed: ${formatVersion(component.installedVersion)}`);
+ }
+ const installation = installationLabel(component.installation);
+ if (installation !== undefined) console.log(` installation: ${installation}`);
+ if (component.error !== undefined) console.log(` ${component.error}`);
+}
+
+function printUnavailableComponent(label: string, error: string | undefined): void {
+ console.log(`? ${label}: unavailable`);
+ if (error !== undefined && error !== "") console.log(` ${error}`);
+}
+
+function installationLabel(installation: PiWebInstallationInfo | undefined): string | undefined {
+ if (installation === undefined) return undefined;
+ if (installation.kind === "pi-package") {
+ const source = installation.source ?? "Pi package";
+ const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`;
+ const path = installation.path === undefined ? "" : ` · ${installation.path}`;
+ return `${source}${scope}${path}`;
+ }
+ if (installation.kind === "npm-global") {
+ const npmRoot = installation.npmRoot === undefined ? "" : ` · ${installation.npmRoot}`;
+ const path = installation.path === undefined ? "" : ` · ${installation.path}`;
+ return `global npm package${npmRoot}${path}`;
+ }
+ if (installation.kind === "local") return installation.path === undefined ? "local checkout" : `local checkout · ${installation.path}`;
+ return installation.path === undefined ? "installation unknown" : `installation unknown · ${installation.path}`;
+}
+
+function formatVersion(version: string | undefined): string {
+ return version === undefined || version === "" ? "unknown" : version;
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/src/server/app.ts b/src/server/app.ts
index e35d503..88ce5a9 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -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 getPiWebStatus());
+ app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
app.get("/api/projects", async () => projects.list());
diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts
index bd6dd42..d71193e 100644
--- a/src/server/piWebStatus.test.ts
+++ b/src/server/piWebStatus.test.ts
@@ -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();
diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts
index f60039e..ba93d12 100644
--- a/src/server/piWebStatus.ts
+++ b/src/server/piWebStatus.ts
@@ -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 {
- const web = await getPiWebComponentStatus("web");
- const [installed, sessiond] = await Promise.all([
- readInstalledPackageInfo(),
+export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise {
+ 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 {
+ 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",
diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts
index 4f49a54..7f4b6d9 100644
--- a/src/server/sessiond.ts
+++ b/src/server/sessiond.ts
@@ -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";
diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts
index 37789d7..8cca535 100644
--- a/src/server/sessiond/sessionProxyRoutes.ts
+++ b/src/server/sessiond/sessionProxyRoutes.ts
@@ -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) => {
diff --git a/src/server/terminalProxyRoutes.ts b/src/server/terminalProxyRoutes.ts
index d5e2dee..cedb7ab 100644
--- a/src/server/terminalProxyRoutes.ts
+++ b/src/server/terminalProxyRoutes.ts
@@ -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";
diff --git a/src/server/sessiond/config.ts b/src/sessiond/config.ts
similarity index 85%
rename from src/server/sessiond/config.ts
rename to src/sessiond/config.ts
index a20e9b3..70b9fcf 100644
--- a/src/server/sessiond/config.ts
+++ b/src/sessiond/config.ts
@@ -1,5 +1,5 @@
import { join } from "node:path";
-import { piWebDataDir } from "../../config.js";
+import { piWebDataDir } from "../config.js";
export function sessiondSocketPath(): string {
return process.env["PI_WEB_SESSIOND_SOCKET"] ?? join(piWebDataDir(), "sessiond.sock");
diff --git a/src/server/sessiond/sessionDaemonClient.ts b/src/sessiond/sessionDaemonClient.ts
similarity index 100%
rename from src/server/sessiond/sessionDaemonClient.ts
rename to src/sessiond/sessionDaemonClient.ts
diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts
index 3d2ae87..14a2c76 100644
--- a/src/shared/apiTypes.ts
+++ b/src/shared/apiTypes.ts
@@ -284,13 +284,16 @@ export interface PiWebStatusMessage {
command?: string;
}
-export interface PiWebStatusResponse {
+export interface PiWebVersionResponse {
packageName: string;
generatedAt: string;
components: {
web: PiWebComponentStatus;
sessiond: PiWebComponentStatus;
};
+}
+
+export interface PiWebStatusResponse extends PiWebVersionResponse {
release: PiWebReleaseStatus;
commands: {
update: string;
diff --git a/src/shared/piWebStatusParsing.ts b/src/shared/piWebStatusParsing.ts
new file mode 100644
index 0000000..a588dc5
--- /dev/null
+++ b/src/shared/piWebStatusParsing.ts
@@ -0,0 +1,58 @@
+import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebVersionResponse } from "./apiTypes.js";
+
+export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
+ if (!isRecord(value)) return undefined;
+ const packageName = value["packageName"];
+ const generatedAt = value["generatedAt"];
+ const components = value["components"];
+ if (typeof packageName !== "string" || packageName === "" || typeof generatedAt !== "string" || generatedAt === "" || !isRecord(components)) return undefined;
+ const web = parsePiWebComponentStatus(components["web"]);
+ const sessiond = parsePiWebComponentStatus(components["sessiond"]);
+ if (web === undefined || sessiond === undefined) return undefined;
+ return { packageName, generatedAt, components: { web, sessiond } };
+}
+
+export function parsePiWebComponentStatus(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 = parsePiWebInstallationInfo(value["installation"]);
+ if (component !== "web" && component !== "sessiond") return undefined;
+ if (typeof label !== "string" || label === "" || 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 } : {}),
+ };
+}
+
+export function parsePiWebInstallationInfo(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 isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}