feat: add local machine registry foundation

This commit is contained in:
Marc Kassubeck
2026-05-26 13:04:00 +02:00
parent 712456f953
commit 0405b384b1
18 changed files with 673 additions and 28 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { activityApi, api, filesApi, gitApi, machinesApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+9
View File
@@ -14,6 +14,8 @@ import {
parseFileTreeResponse,
parseGitDiffResponse,
parseGitStatusResponse,
parseMachine,
parseMachinesResponse,
parseMessagePage,
parseModelSelectionResponse,
parseOAuthFlowState,
@@ -36,6 +38,12 @@ export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
};
export const machinesApi = {
machines: () => request("/api/machines", parseMachinesResponse),
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
};
export const activityApi = {
workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse),
};
@@ -144,6 +152,7 @@ export const gitApi = {
export const api = {
...piWebApi,
...machinesApi,
...activityApi,
...projectsApi,
...workspacesApi,
+37 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -57,6 +57,42 @@ export function parseMessagePage(value: unknown): MessagePage {
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
}
export function parseMachinesResponse(value: unknown): Machine[] {
const record = requireRecord(value);
return arrayOf(parseMachine)(record["machines"]);
}
export function parseMachine(value: unknown): Machine {
const record = requireRecord(value);
const kind = requireMachineKind(record, "kind");
const baseUrl = optionalString(record, "baseUrl");
const status = optionalMachineStatus(record, "status");
const statusMessage = optionalString(record, "statusMessage");
return {
id: requireString(record, "id"),
name: requireString(record, "name"),
kind,
...(baseUrl === undefined ? {} : { baseUrl }),
createdAt: requireString(record, "createdAt"),
updatedAt: requireString(record, "updatedAt"),
...(status === undefined ? {} : { status }),
...(statusMessage === undefined ? {} : { statusMessage }),
};
}
function requireMachineKind(record: Record<string, unknown>, key: string): MachineKind {
const value = requireString(record, key);
if (value !== "local" && value !== "remote") throw new Error(`Expected machine kind field: ${key}`);
return value;
}
function optionalMachineStatus(record: Record<string, unknown>, key: string): MachineStatus | undefined {
const value = optionalString(record, key);
if (value === undefined) return undefined;
if (value !== "unknown" && value !== "online" && value !== "offline" && value !== "error") throw new Error(`Expected machine status field: ${key}`);
return value;
}
export function parseProject(value: unknown): Project {
const record = requireRecord(value);
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
+9 -1
View File
@@ -1,8 +1,12 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids";
export interface AppState {
machines: Machine[];
selectedMachine: Machine | undefined;
isLoadingMachines: boolean;
machineStatuses: Record<string, MachineHealth>;
projects: Project[];
workspaces: Workspace[];
sessions: SessionInfo[];
@@ -91,6 +95,10 @@ export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
export function initialAppState(): AppState {
return {
machines: [],
selectedMachine: undefined,
isLoadingMachines: false,
machineStatuses: {},
projects: [],
workspaces: [],
sessions: [],
+45
View File
@@ -0,0 +1,45 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { Machine } from "../api";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@customElement("machine-list")
export class MachineList extends LitElement {
@property({ attribute: false }) machines: Machine[] = [];
@property({ attribute: false }) selected?: Machine;
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
@property({ attribute: false }) onToggleCollapsed?: () => void;
override render() {
return html`
<section>
<h2>${this.renderHeading()}</h2>
${this.collapsed ? null : this.machines.map((machine) => html`
<div
class=${`action-row ${this.selected?.id === machine.id ? "selected" : ""}`}
tabindex="0"
title=${machine.baseUrl ?? machine.name}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }}
>
<div class="action-main">
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl}</small>
</div>
</div>
`)}
</section>
`;
}
private renderHeading() {
if (!this.collapsible) return "Machines";
const selectedSummary = this.selected?.name ?? "No machine selected";
const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.machines.length}</small></button>`;
}
static override styles = listStyles;
}
+24 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import { piWebApi, terminalsApi, type Machine, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -8,6 +8,7 @@ import { ActivityController } from "../controllers/activityController";
import { AuthController } from "../controllers/authController";
import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
import { MachineController } from "../controllers/machineController";
import { ProjectController } from "../controllers/projectController";
import { SessionController } from "../controllers/sessionController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
@@ -25,6 +26,7 @@ import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMo
import { readRoute, writeRoute, type AppRoute } from "../route";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
import "./MachineList";
import "./ProjectList";
import "./WorkspaceList";
import "./SessionList";
@@ -42,7 +44,7 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import { actionMenuPanelStyle } from "./actionMenu";
import { appStyles } from "./shared";
type NavigationSection = "projects" | "workspaces" | "sessions";
type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
@@ -86,6 +88,12 @@ export class PiWebApp extends LitElement {
(patch) => { this.setState(patch); },
this.workspaces,
);
private readonly machines = new MachineController(
() => this.state,
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
this.projects,
);
private readonly files = new FileExplorerController(
() => this.state,
(patch) => { this.setState(patch); },
@@ -252,6 +260,8 @@ export class PiWebApp extends LitElement {
}
private async loadProjectsAndRestoreRoute() {
const route = readRoute();
await this.machines.loadMachines(route.machineId);
await this.projects.loadProjects();
await this.withChatScrollTransition(() => this.restoreRoute(false));
await this.refreshWorkspaceDeletionRuns();
@@ -368,6 +378,7 @@ export class PiWebApp extends LitElement {
private updateUrl(options?: { replace?: boolean | undefined }) {
writeRoute({
machineId: this.state.selectedMachine?.id,
projectId: this.state.selectedProject?.id,
workspaceId: this.state.selectedWorkspace?.id,
sessionId: this.state.selectedSession?.id,
@@ -528,6 +539,17 @@ export class PiWebApp extends LitElement {
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
</div>
</header>
<machine-list
.machines=${this.state.machines}
.selected=${this.state.selectedMachine}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("machines")}
.onToggleCollapsed=${() => { this.toggleNavigationSection("machines"); }}
.onSelect=${(machine: Machine) => this.withChatScrollTransition(async () => {
this.expandNavigationSection("projects");
await this.machines.selectMachine(machine);
})}
></machine-list>
<project-list
.projects=${this.state.projects}
.selected=${this.state.selectedProject}
@@ -0,0 +1,41 @@
import { api, type Machine } from "../api";
import { resetWorkspaceScopedState } from "../appState";
import type { GetState, SetState, UpdateUrl } from "./types";
import type { ProjectController } from "./projectController";
export class MachineController {
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly projects: ProjectController) {}
async loadMachines(routeMachineId?: string): Promise<void> {
this.setState({ error: "", isLoadingMachines: true });
try {
const machines = await api.machines();
const selectedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local")) ?? machines.find((machine) => machine.id === "local") ?? machines[0];
this.setState({ machines, selectedMachine });
} catch (error) {
this.setState({ error: String(error) });
} finally {
this.setState({ isLoadingMachines: false });
}
}
async selectMachine(machine: Machine): Promise<void> {
if (this.getState().selectedMachine?.id === machine.id) return;
this.setState({
selectedMachine: machine,
projects: [],
workspaces: [],
selectedProject: undefined,
selectedWorkspace: undefined,
selectedSession: undefined,
messages: [],
messagePageStart: 0,
messagePageTotal: 0,
status: undefined,
activity: undefined,
...resetWorkspaceScopedState(),
});
this.updateUrl();
await this.projects.loadProjects();
}
}
+5 -3
View File
@@ -33,9 +33,10 @@ function installWindow(href: string): { pushed: string[] } {
describe("route helpers", () => {
it("reads only supported route fields from the current URL", () => {
installWindow("http://localhost/app?project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
installWindow("http://localhost/app?machine=remote&project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
expect(readRoute()).toEqual({
machineId: "remote",
projectId: "p1",
workspaceId: "w1",
sessionId: "s1",
@@ -53,6 +54,7 @@ describe("route helpers", () => {
it("writes compact URLs and preserves path/hash", () => {
const { pushed } = installWindow("http://localhost/app?old=1#section");
const route: AppRoute = {
machineId: "remote",
projectId: "project/id",
workspaceId: "workspace id",
sessionId: "",
@@ -62,13 +64,13 @@ describe("route helpers", () => {
writeRoute(route);
expect(pushed).toEqual(["http://localhost/app?old=1&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
expect(pushed).toEqual(["http://localhost/app?old=1&machine=remote&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
});
it("does not push history when the route is unchanged", () => {
const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git");
writeRoute({ projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
writeRoute({ machineId: undefined, projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
expect(pushed).toEqual([]);
});
+4
View File
@@ -1,6 +1,7 @@
import type { QualifiedContributionId } from "./plugins/types";
export interface AppRoute {
machineId: string | undefined;
projectId: string | undefined;
workspaceId: string | undefined;
sessionId: string | undefined;
@@ -11,6 +12,7 @@ export interface AppRoute {
export function readRoute(): AppRoute {
const params = new URLSearchParams(window.location.search);
return {
machineId: params.get("machine") ?? undefined,
projectId: params.get("project") ?? undefined,
workspaceId: params.get("workspace") ?? undefined,
sessionId: params.get("session") ?? undefined,
@@ -21,11 +23,13 @@ export function readRoute(): AppRoute {
export function writeRoute(route: AppRoute, options?: { replace?: boolean | undefined }): void {
const url = new URL(window.location.href);
url.searchParams.delete("machine");
url.searchParams.delete("project");
url.searchParams.delete("workspace");
url.searchParams.delete("session");
url.searchParams.delete("tool");
url.searchParams.delete("view");
if (route.machineId !== undefined && route.machineId !== "" && route.machineId !== "local") url.searchParams.set("machine", route.machineId);
if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);
+18
View File
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildApp } from "./app.js";
import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js";
import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { Project, Workspace } from "./types.js";
@@ -20,6 +22,7 @@ beforeEach(async () => {
app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(),
machines: new MachineService(new MachineStore(join(tempDir, "machines.json"))),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
@@ -35,6 +38,21 @@ afterEach(async () => {
});
describe("buildApp", () => {
it("lists synthesized local machine through the HTTP contract", async () => {
const response = await app.inject({ method: "GET", url: "/api/machines" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ machines: [{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }] });
});
it("adds remote machines without exposing tokens", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/", token: "secret" } });
expect(addResponse.statusCode).toBe(200);
expect(addResponse.json()).toMatchObject({ name: "Remote", kind: "remote", baseUrl: "https://remote.example.test" });
expect(addResponse.json()).not.toHaveProperty("token");
});
it("adds, lists, and closes projects through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
+6
View File
@@ -15,10 +15,13 @@ import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { getPiWebStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
export interface AppDependencies {
projects?: ProjectService;
workspaces?: WorkspaceService;
machines?: MachineService;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "readAsset">;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
@@ -31,6 +34,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const machines = deps.machines ?? new MachineService();
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
@@ -42,6 +46,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/status", async () => getPiWebStatus());
registerMachineRoutes(app, machines);
app.get("/api/projects", async () => projects.list());
app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => {
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyInstance } from "fastify";
import { MachineService, type CreateMachineInput, type UpdateMachineInput } from "./machineService.js";
export function registerMachineRoutes(app: FastifyInstance, machines = new MachineService()): void {
app.get("/api/machines", async () => ({ machines: await machines.list() }));
app.post<{ Body: CreateMachineInput }>("/api/machines", async (request, reply) => {
try {
return await machines.add(request.body);
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
const machine = await machines.get(request.params.machineId);
if (machine === undefined) return reply.code(404).send({ error: "Machine not found" });
return machine;
});
app.patch<{ Params: { machineId: string }; Body: UpdateMachineInput }>("/api/machines/:machineId", async (request, reply) => {
try {
const machine = await machines.update(request.params.machineId, request.body);
if (machine === undefined) return await reply.code(404).send({ error: "Machine not found" });
return machine;
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.delete<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
try {
const removed = await machines.remove(request.params.machineId);
if (!removed) return await reply.code(404).send({ error: "Machine not found" });
return { deleted: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,55 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MachineService } from "./machineService.js";
import { MachineStore, machineStorePath } from "./machineStore.js";
let tempDir: string;
let storePath: string;
let service: MachineService;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-machines-test-"));
storePath = join(tempDir, "machines.json");
service = new MachineService(new MachineStore(storePath));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("MachineService", () => {
it("synthesizes local machine without persisting it", async () => {
expect(await service.list()).toEqual([
{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" },
]);
});
it("adds remote machines and omits secrets from public responses", async () => {
const machine = await service.add({ name: " Dev Box ", baseUrl: "https://devbox.example.test/", token: "secret" });
expect(machine).toMatchObject({ name: "Dev Box", kind: "remote", baseUrl: "https://devbox.example.test" });
expect(machine).not.toHaveProperty("token");
expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" }), machine]);
const raw: unknown = JSON.parse(await readFile(storePath, "utf8"));
expect(raw).toMatchObject({ machines: [expect.objectContaining({ kind: "remote", token: "secret" })] });
});
it("rejects invalid remote base URLs", async () => {
await expect(service.add({ name: "Bad", baseUrl: "ftp://example.test" })).rejects.toThrow("http or https");
await expect(service.add({ name: "Bad", baseUrl: "https://[email protected]" })).rejects.toThrow("credentials");
await expect(service.add({ name: "Bad", baseUrl: "https://example.test/path?q=1" })).rejects.toThrow("query or hash");
});
it("does not allow local machine mutation", async () => {
await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed");
await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted");
});
it("supports PI_WEB_MACHINES_FILE path overrides", () => {
const env: NodeJS.ProcessEnv = { PI_WEB_MACHINES_FILE: "data/machines.json" };
expect(machineStorePath(env, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "data/machines.json"));
});
});
+93
View File
@@ -0,0 +1,93 @@
import type { Machine } from "../../shared/apiTypes.js";
import { MachineStore, type StoredMachine } from "./machineStore.js";
export interface CreateMachineInput {
name?: string;
baseUrl?: string;
token?: string;
headers?: Record<string, string>;
}
export type UpdateMachineInput = Partial<CreateMachineInput>;
const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z";
export class MachineService {
constructor(private readonly store = new MachineStore()) {}
async list(): Promise<Machine[]> {
return [localMachine(), ...(await this.store.list()).map(publicMachine)];
}
async get(id: string): Promise<Machine | undefined> {
if (id === "local") return localMachine();
const machine = (await this.store.list()).find((stored) => stored.id === id);
return machine === undefined ? undefined : publicMachine(machine);
}
async add(input: CreateMachineInput): Promise<Machine> {
const name = validateName(input.name);
const baseUrl = validateBaseUrl(input.baseUrl);
const stored = await this.store.add({ name, baseUrl, ...optionalSecrets(input) });
return publicMachine(stored);
}
async update(id: string, input: UpdateMachineInput): Promise<Machine | undefined> {
if (id === "local") throw new Error("Local machine cannot be changed");
const patch: Partial<Pick<StoredMachine, "name" | "baseUrl" | "token" | "headers">> = {};
if (input.name !== undefined) patch.name = validateName(input.name);
if (input.baseUrl !== undefined) patch.baseUrl = validateBaseUrl(input.baseUrl);
if (input.token !== undefined) patch.token = input.token;
if (input.headers !== undefined) patch.headers = validateHeaders(input.headers);
const stored = await this.store.update(id, patch);
return stored === undefined ? undefined : publicMachine(stored);
}
async remove(id: string): Promise<boolean> {
if (id === "local") throw new Error("Local machine cannot be deleted");
return await this.store.remove(id);
}
}
export function localMachine(): Machine {
return { id: "local", name: "Local", kind: "local", createdAt: LOCAL_MACHINE_TIMESTAMP, updatedAt: LOCAL_MACHINE_TIMESTAMP };
}
function publicMachine(machine: StoredMachine): Machine {
return { id: machine.id, name: machine.name, kind: "remote", baseUrl: machine.baseUrl, createdAt: machine.createdAt, updatedAt: machine.updatedAt };
}
function validateName(value: string | undefined): string {
const name = value?.trim();
if (name === undefined || name === "") throw new Error("Machine name is required");
return name;
}
function validateBaseUrl(value: string | undefined): string {
const raw = value?.trim();
if (raw === undefined || raw === "") throw new Error("Machine baseUrl is required");
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error("Machine baseUrl must be a valid URL");
}
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Machine baseUrl must use http or https");
if (url.username !== "" || url.password !== "") throw new Error("Machine baseUrl must not include credentials");
if (url.search !== "" || url.hash !== "") throw new Error("Machine baseUrl must not include query or hash");
return url.href.replace(/\/$/u, "");
}
function optionalSecrets(input: CreateMachineInput): { token?: string; headers?: Record<string, string> } {
return {
...(input.token === undefined ? {} : { token: input.token }),
...(input.headers === undefined ? {} : { headers: validateHeaders(input.headers) }),
};
}
function validateHeaders(value: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(value).map(([key, headerValue]) => {
if (typeof headerValue !== "string") throw new Error("Machine headers must be strings");
return [key, headerValue];
}));
}
+132
View File
@@ -0,0 +1,132 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { piWebDataDir } from "../../config.js";
export interface StoredMachine {
id: string;
name: string;
kind: "remote";
baseUrl: string;
token?: string;
headers?: Record<string, string>;
createdAt: string;
updatedAt: string;
}
interface MachineFile {
machines: StoredMachine[];
}
export function defaultMachineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
return join(piWebDataDir(env, cwd), "machines.json");
}
export function machineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
const configured = env["PI_WEB_MACHINES_FILE"];
if (configured === undefined || configured === "") return defaultMachineStorePath(env, cwd);
return resolve(cwd, configured);
}
export class MachineStore {
constructor(private readonly filePath = machineStorePath()) {}
async list(): Promise<StoredMachine[]> {
return (await this.read()).machines;
}
async add(input: { name: string; baseUrl: string; token?: string; headers?: Record<string, string> }): Promise<StoredMachine> {
const data = await this.read();
const now = new Date().toISOString();
const machine: StoredMachine = {
id: randomUUID(),
name: input.name,
kind: "remote",
baseUrl: input.baseUrl,
...(input.token === undefined ? {} : { token: input.token }),
...(input.headers === undefined ? {} : { headers: input.headers }),
createdAt: now,
updatedAt: now,
};
data.machines.push(machine);
await this.write(data);
return machine;
}
async update(id: string, patch: Partial<Pick<StoredMachine, "name" | "baseUrl" | "token" | "headers">>): Promise<StoredMachine | undefined> {
const data = await this.read();
const index = data.machines.findIndex((machine) => machine.id === id);
if (index < 0) return undefined;
const current = data.machines[index];
if (current === undefined) return undefined;
const next: StoredMachine = { ...current, ...patch, updatedAt: new Date().toISOString() };
data.machines[index] = next;
await this.write(data);
return next;
}
async remove(id: string): Promise<boolean> {
const data = await this.read();
const machines = data.machines.filter((machine) => machine.id !== id);
if (machines.length === data.machines.length) return false;
await this.write({ machines });
return true;
}
private async read(): Promise<MachineFile> {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
return parseMachineFile(value);
} catch (error) {
if (isNodeErrorWithCode(error, "ENOENT")) return { machines: [] };
throw error;
}
}
private async write(data: MachineFile): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
}
function parseMachineFile(value: unknown): MachineFile {
if (!isRecord(value) || !Array.isArray(value["machines"])) throw new Error("Invalid machine file");
return { machines: value["machines"].map(parseStoredMachine) };
}
function parseStoredMachine(value: unknown): StoredMachine {
if (!isRecord(value)) throw new Error("Invalid machine");
const id = value["id"];
const name = value["name"];
const kind = value["kind"];
const baseUrl = value["baseUrl"];
const createdAt = value["createdAt"];
const updatedAt = value["updatedAt"];
if (typeof id !== "string" || typeof name !== "string" || kind !== "remote" || typeof baseUrl !== "string" || typeof createdAt !== "string" || typeof updatedAt !== "string") throw new Error("Invalid machine");
const token = optionalString(value["token"], "token");
const headers = optionalStringRecord(value["headers"], "headers");
return { id, name, kind, baseUrl, createdAt, updatedAt, ...(token === undefined ? {} : { token }), ...(headers === undefined ? {} : { headers }) };
}
function optionalString(value: unknown, key: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") throw new Error(`Invalid machine ${key}`);
return value;
}
function optionalStringRecord(value: unknown, key: string): Record<string, string> | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) throw new Error(`Invalid machine ${key}`);
return Object.fromEntries(Object.entries(value).map(([header, headerValue]) => {
if (typeof headerValue !== "string") throw new Error(`Invalid machine ${key}`);
return [header, headerValue];
}));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+24
View File
@@ -1,3 +1,27 @@
export type MachineKind = "local" | "remote";
export type MachineStatus = "unknown" | "online" | "offline" | "error";
export interface Machine {
id: string;
name: string;
kind: MachineKind;
baseUrl?: string;
createdAt: string;
updatedAt: string;
status?: MachineStatus;
statusMessage?: string;
}
export interface MachineHealth {
machineId: string;
ok: boolean;
checkedAt: string;
status?: MachineStatus;
web?: PiWebComponentStatus;
sessiond?: PiWebComponentStatus;
error?: string;
}
export interface Project {
id: string;
name: string;