Archived
feat: manage Pi packages on selected machines
This commit is contained in:
+32
-1
@@ -14,6 +14,7 @@ import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import type { PiPackageService } from "./piPackageService.js";
|
||||
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
|
||||
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
@@ -159,6 +160,28 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ method, path, body })]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-packages` });
|
||||
const installBody = { source: "npm:@acme/new-tools" };
|
||||
const installResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/pi-packages/install`, payload: installBody });
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/pi-packages" });
|
||||
expect(installResponse.statusCode).toBe(200);
|
||||
expect(installResponse.json()).toEqual({ method: "POST", path: "/api/pi-packages/install", body: installBody });
|
||||
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/pi-packages", undefined);
|
||||
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS });
|
||||
});
|
||||
|
||||
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -401,7 +424,15 @@ describe("buildApp", () => {
|
||||
const installResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools" } });
|
||||
expect(installResponse.statusCode).toBe(200);
|
||||
expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
|
||||
expect(piPackageRequests).toEqual([{ action: "list" }, { action: "install", source: "npm:@acme/new-tools" }]);
|
||||
|
||||
const localAliasResponse = await app.inject({ method: "POST", url: "/api/machines/local/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "user" } });
|
||||
expect(localAliasResponse.statusCode).toBe(200);
|
||||
expect(localAliasResponse.json()).toMatchObject({ action: "remove", source: "npm:@acme/tools", scope: "user" });
|
||||
expect(piPackageRequests).toEqual([
|
||||
{ action: "list" },
|
||||
{ action: "install", source: "npm:@acme/new-tools" },
|
||||
{ action: "remove", source: "npm:@acme/tools", scope: "user" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||
|
||||
@@ -150,6 +150,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
registerPiPackageRoutes(app, piPackages);
|
||||
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
|
||||
registerConfigRoutes(app, configService);
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { WebSocket } from "ws";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
|
||||
import { bridgeSockets } from "../webSocketBridge.js";
|
||||
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
|
||||
import { MachineService } from "./machineService.js";
|
||||
@@ -23,7 +23,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
app.route<{ Params: { machineId: string }; Body: unknown }>({
|
||||
method: spec.method,
|
||||
url: `/api/machines/:machineId${spec.path}`,
|
||||
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
|
||||
handler: (request, reply) => proxyHttpRequest(machines, spec, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
|
||||
async function proxyHttpRequest(machines: MachineService, spec: FederatedHttpRouteSpec, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
|
||||
if (machineId === "local") {
|
||||
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
|
||||
}
|
||||
@@ -45,7 +45,7 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
|
||||
}
|
||||
|
||||
try {
|
||||
const requestOptions = proxyRequestOptions(body, contentType);
|
||||
const requestOptions = proxyRequestOptions(spec, body, contentType);
|
||||
const upstream = requestOptions === undefined
|
||||
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
|
||||
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
|
||||
@@ -84,10 +84,14 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
|
||||
return `/api${compatPath}`;
|
||||
}
|
||||
|
||||
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
||||
if (!isRawProxyBody(body)) return undefined;
|
||||
const value = firstHeaderValue(contentType);
|
||||
return value === undefined || value === "" ? undefined : { contentType: value };
|
||||
function proxyRequestOptions(spec: Pick<FederatedHttpRouteSpec, "timeoutMs">, body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
||||
const options: MachineRequestOptions = {};
|
||||
if (spec.timeoutMs !== undefined) options.timeoutMs = spec.timeoutMs;
|
||||
if (isRawProxyBody(body)) {
|
||||
const value = firstHeaderValue(contentType);
|
||||
if (value !== undefined && value !== "") options.contentType = value;
|
||||
}
|
||||
return Object.keys(options).length === 0 ? undefined : options;
|
||||
}
|
||||
|
||||
function isRawProxyBody(body: unknown): boolean {
|
||||
|
||||
@@ -29,6 +29,23 @@ describe("registerPiPackageRoutes", () => {
|
||||
expect(serviceMocks.list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("registers package routes under a custom API prefix", async () => {
|
||||
const prefixedApp = Fastify({ logger: false });
|
||||
const prefixedMocks = fakePiPackageService();
|
||||
registerPiPackageRoutes(prefixedApp, prefixedMocks.service, "/api/machines/local");
|
||||
await prefixedApp.ready();
|
||||
|
||||
try {
|
||||
const response = await prefixedApp.inject({ method: "GET", url: "/api/machines/local/pi-packages" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
|
||||
expect(prefixedMocks.list).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await prefixedApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("installs a trimmed Pi package source without accepting a scope", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: " npm:@acme/new-tools " } });
|
||||
const scopedResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools", scope: "project" } });
|
||||
|
||||
@@ -4,8 +4,10 @@ import { createDefaultPiPackageService, type PiPackageService } from "./piPackag
|
||||
|
||||
class PiPackageRequestValidationError extends Error {}
|
||||
|
||||
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService()): void {
|
||||
app.get("/api/pi-packages", async (_request, reply) => {
|
||||
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void {
|
||||
const routePrefix = normalizeRoutePrefix(prefix);
|
||||
|
||||
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
|
||||
try {
|
||||
return await service.list();
|
||||
} catch (error) {
|
||||
@@ -13,7 +15,7 @@ export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackage
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>("/api/pi-packages/install", async (request, reply) => {
|
||||
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/install`, async (request, reply) => {
|
||||
try {
|
||||
return await service.install(parseRequiredSourceRequest(request.body));
|
||||
} catch (error) {
|
||||
@@ -21,7 +23,7 @@ export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackage
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>("/api/pi-packages/remove", async (request, reply) => {
|
||||
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/remove`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRequestObject(request.body);
|
||||
return await service.remove(parseRequiredSource(body["source"]), parseOptionalScope(body["scope"]));
|
||||
@@ -30,7 +32,7 @@ export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackage
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>("/api/pi-packages/update", async (request, reply) => {
|
||||
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/update`, async (request, reply) => {
|
||||
try {
|
||||
const source = parseOptionalUpdateSource(request.body);
|
||||
return source === undefined ? await service.update() : await service.update(source);
|
||||
@@ -40,6 +42,11 @@ export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackage
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoutePrefix(prefix: string): string {
|
||||
const normalized = prefix.replace(/\/+$/u, "");
|
||||
return normalized === "" ? "/api" : normalized;
|
||||
}
|
||||
|
||||
function parseRequiredSourceRequest(body: unknown): string {
|
||||
const request = requireRequestObject(body);
|
||||
if (request["scope"] !== undefined || request["local"] !== undefined) {
|
||||
|
||||
Reference in New Issue
Block a user