feat: add Pi package management settings

This commit is contained in:
Federico Jaramillo Martinez
2026-07-01 15:34:34 +02:00
parent 3f36394c17
commit 8ade238228
28 changed files with 1009 additions and 51 deletions
+97
View File
@@ -0,0 +1,97 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js";
import type { PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
let app: FastifyInstance;
let service: PiPackageService;
let serviceMocks: ReturnType<typeof fakePiPackageService>;
beforeEach(async () => {
serviceMocks = fakePiPackageService();
service = serviceMocks.service;
app = Fastify({ logger: false });
registerPiPackageRoutes(app, service);
await app.ready();
});
afterEach(async () => {
await app.close();
});
describe("registerPiPackageRoutes", () => {
it("lists configured Pi packages", async () => {
const response = await app.inject({ method: "GET", url: "/api/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(serviceMocks.list).toHaveBeenCalledOnce();
});
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" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
expect(scopedResponse.statusCode).toBe(400);
expect(scopedResponse.json()).toEqual({ error: "Pi package install scope is not supported; installs use Pi's default package location" });
expect(serviceMocks.install).toHaveBeenCalledOnce();
expect(serviceMocks.install).toHaveBeenCalledWith("npm:@acme/new-tools");
});
it("removes from an explicitly listed package scope", async () => {
const response = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "../project-tools", scope: "project" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ action: "remove", source: "../project-tools", scope: "project", removed: true });
expect(serviceMocks.remove).toHaveBeenCalledWith("../project-tools", "project");
});
it("updates all packages when source is omitted and one package when source is provided", async () => {
const allResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update" });
const oneResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: " npm:@acme/tools " } });
expect(allResponse.statusCode).toBe(200);
expect(oneResponse.statusCode).toBe(200);
expect(serviceMocks.update).toHaveBeenNthCalledWith(1);
expect(serviceMocks.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
});
it("returns stable 400 errors for invalid requests before calling the service", async () => {
const missingSource = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: {} });
const blankSource = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: " " } });
const invalidScope = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "temporary" } });
const invalidUpdate = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: "" } });
expect(missingSource.statusCode).toBe(400);
expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
expect(blankSource.statusCode).toBe(400);
expect(invalidScope.statusCode).toBe(400);
expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" });
expect(invalidUpdate.statusCode).toBe(400);
expect(serviceMocks.install).not.toHaveBeenCalled();
expect(serviceMocks.remove).not.toHaveBeenCalled();
expect(serviceMocks.update).not.toHaveBeenCalled();
});
it("returns stable 500 errors for package-manager failures", async () => {
serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/fails" } });
expect(response.statusCode).toBe(500);
expect(response.json()).toEqual({ error: "install failed" });
});
});
function fakePiPackageService() {
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const list = vi.fn<PiPackageService["list"]>(() => Promise.resolve({ packages: [...packages] }));
const install = vi.fn<PiPackageService["install"]>((source) => Promise.resolve({ action: "install", source, packages: [...packages] }));
const remove = vi.fn<PiPackageService["remove"]>((source, scope = "user") => Promise.resolve({ action: "remove", source, scope, removed: true, packages: [...packages] }));
const update = vi.fn<PiPackageService["update"]>((source) => Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages: [...packages] }));
const service: PiPackageService = { list, install, remove, update };
return { service, list, install, remove, update };
}