diff --git a/.changeset/pi-web-version-doctor.md b/.changeset/pi-web-version-doctor.md new file mode 100644 index 0000000..c08e542 --- /dev/null +++ b/.changeset/pi-web-version-doctor.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add `pi-web version` and include installed and running PI WEB version details in doctor output. diff --git a/README.md b/README.md index 344a195..7ddd505 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,12 @@ pi-web status pi-web logs pi-web restart pi-web doctor +pi-web version pi-web uninstall ``` +Use `pi-web version` to compare the installed package version with the versions reported by the running Web/UI and session daemon services. + One-line install is also available for users who prefer it: ```bash @@ -173,6 +176,7 @@ Then in Pi: /pi-web logs /pi-web restart /pi-web doctor +/pi-web version ``` The Pi command is a convenience wrapper around the same service installer. When installed this way, the service installer can use PI WEB's package-local server entrypoints, so `pi-web-server` and `pi-web-sessiond` do not need to be on your shell `PATH`. `/pi-web logs` shows the last 100 service log lines; use `pi-web logs` in a shell when you want to follow logs continuously. diff --git a/docs/faq.html b/docs/faq.html index a643853..f4c9a4b 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -121,7 +121,8 @@

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 reports user service lingering when relevant for server-style installs. + Web binaries. It also prints installed and running PI WEB versions when available, and reports user service + lingering when relevant for server-style installs.

If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and diff --git a/docs/index.html b/docs/index.html index 5f3c11b..ea0ea08 100644 --- a/docs/index.html +++ b/docs/index.html @@ -257,6 +257,7 @@

$ npm install -g @jmfederico/pi-web
 $ pi-web install
 $ pi-web doctor
+$ pi-web version
 # Open http://127.0.0.1:8504
diff --git a/docs/install.html b/docs/install.html index b929625..c469876 100644 --- a/docs/install.html +++ b/docs/install.html @@ -138,7 +138,8 @@ /pi-web install /pi-web status /pi-web logs -/pi-web doctor +/pi-web doctor +/pi-web version @@ -195,6 +196,7 @@

Manage services

+

pi-web version compares the installed package version with the versions reported by the running Web/UI and session daemon services.

Useful commands @@ -204,6 +206,7 @@ $ pi-web logs $ pi-web restart $ pi-web doctor +$ pi-web version # From a checkout, install the split development services: $ 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); +}