Archived
feat: add local machine registry foundation
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user