Archived
feat: add Pi package management settings
This commit is contained in:
+44
-1
@@ -11,11 +11,12 @@ import { RemoteMachineRequestError, type MachineClient } from "./machines/machin
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { MachineStore } from "./machines/machineStore.js";
|
||||
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 { machineScopedPluginId } from "../shared/machinePluginIds.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -23,6 +24,7 @@ let tempDir: string;
|
||||
let projectDir: string;
|
||||
let remoteClient: MachineClient | undefined;
|
||||
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
|
||||
let piPackageRequests: CapturedPiPackageRequest[];
|
||||
let piWebConfig: PiWebConfigValues;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -30,6 +32,7 @@ beforeEach(async () => {
|
||||
projectDir = join(tempDir, "project");
|
||||
remoteClient = undefined;
|
||||
sessionDaemonRequests = [];
|
||||
piPackageRequests = [];
|
||||
piWebConfig = {};
|
||||
app = await buildApp({
|
||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||
@@ -52,6 +55,7 @@ beforeEach(async () => {
|
||||
}),
|
||||
sessionDaemon: fakeSessionDaemon(),
|
||||
config: fakeConfigService(),
|
||||
piPackages: fakePiPackageService(),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
|
||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
|
||||
@@ -389,6 +393,17 @@ describe("buildApp", () => {
|
||||
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
|
||||
});
|
||||
|
||||
it("serves Pi package management routes through the app wiring", async () => {
|
||||
const listResponse = await app.inject({ method: "GET", url: "/api/pi-packages" });
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }] });
|
||||
|
||||
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" }]);
|
||||
});
|
||||
|
||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
@@ -885,6 +900,12 @@ interface CapturedSessionDaemonRequest {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
interface CapturedPiPackageRequest {
|
||||
action: "list" | "install" | "remove" | "update";
|
||||
source?: string;
|
||||
scope?: "user" | "project";
|
||||
}
|
||||
|
||||
function fakeConfigService() {
|
||||
return {
|
||||
read: () => piWebConfigResponse(piWebConfig),
|
||||
@@ -905,6 +926,28 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function fakePiPackageService(): PiPackageService {
|
||||
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }];
|
||||
return {
|
||||
list: () => {
|
||||
piPackageRequests.push({ action: "list" });
|
||||
return Promise.resolve({ packages });
|
||||
},
|
||||
install: (source) => {
|
||||
piPackageRequests.push({ action: "install", source });
|
||||
return Promise.resolve({ action: "install", source, packages });
|
||||
},
|
||||
remove: (source, scope = "user") => {
|
||||
piPackageRequests.push({ action: "remove", source, scope });
|
||||
return Promise.resolve({ action: "remove", source, scope, removed: true, packages });
|
||||
},
|
||||
update: (source) => {
|
||||
piPackageRequests.push({ action: "update", ...(source === undefined ? {} : { source }) });
|
||||
return Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeSessionDaemon(): SessionProxyDaemon {
|
||||
return {
|
||||
request: (method, path, body) => {
|
||||
|
||||
@@ -20,6 +20,8 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
@@ -34,6 +36,7 @@ export interface AppDependencies {
|
||||
machines?: MachineService;
|
||||
sessionDaemon?: SessionProxyDaemon;
|
||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
||||
piPackages?: PiPackageService;
|
||||
config?: PiWebConfigService;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
@@ -122,6 +125,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 piPackages = deps.piPackages ?? createDefaultPiPackageService();
|
||||
const configService = deps.config ?? createFilePiWebConfigService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
|
||||
@@ -145,6 +149,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
registerPiPackageRoutes(app, piPackages);
|
||||
registerConfigRoutes(app, configService);
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { PiPackageScope } from "../shared/apiTypes.js";
|
||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||
|
||||
class PiPackageRequestValidationError extends Error {}
|
||||
|
||||
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService()): void {
|
||||
app.get("/api/pi-packages", async (_request, reply) => {
|
||||
try {
|
||||
return await service.list();
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>("/api/pi-packages/install", async (request, reply) => {
|
||||
try {
|
||||
return await service.install(parseRequiredSourceRequest(request.body));
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>("/api/pi-packages/remove", async (request, reply) => {
|
||||
try {
|
||||
const body = requireRequestObject(request.body);
|
||||
return await service.remove(parseRequiredSource(body["source"]), parseOptionalScope(body["scope"]));
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>("/api/pi-packages/update", async (request, reply) => {
|
||||
try {
|
||||
const source = parseOptionalUpdateSource(request.body);
|
||||
return source === undefined ? await service.update() : await service.update(source);
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseRequiredSourceRequest(body: unknown): string {
|
||||
const request = requireRequestObject(body);
|
||||
if (request["scope"] !== undefined || request["local"] !== undefined) {
|
||||
throw new PiPackageRequestValidationError("Pi package install scope is not supported; installs use Pi's default package location");
|
||||
}
|
||||
return parseRequiredSource(request["source"]);
|
||||
}
|
||||
|
||||
function parseRequiredSource(value: unknown): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new PiPackageRequestValidationError("Pi package source must be a non-empty string");
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parseOptionalUpdateSource(body: unknown): string | undefined {
|
||||
if (body === undefined) return undefined;
|
||||
const source = requireRequestObject(body)["source"];
|
||||
if (source === undefined) return undefined;
|
||||
return parseRequiredSource(source);
|
||||
}
|
||||
|
||||
function parseOptionalScope(value: unknown): PiPackageScope | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value !== "user" && value !== "project") throw new PiPackageRequestValidationError("Pi package scope must be \"user\" or \"project\"");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireRequestObject(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new PiPackageRequestValidationError("Pi package request body must be an object");
|
||||
return value;
|
||||
}
|
||||
|
||||
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
|
||||
const status = error instanceof PiPackageRequestValidationError ? 400 : 500;
|
||||
return reply.code(status).send({ error: errorMessage(error) });
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiPackageInfo } from "../shared/apiTypes.js";
|
||||
import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js";
|
||||
|
||||
function fakeManager(packages: PiPackageInfo[] = []) {
|
||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
|
||||
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(() => Promise.resolve());
|
||||
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
|
||||
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
|
||||
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
|
||||
return { manager, listConfiguredPackages, installAndPersist, removeAndPersist, update };
|
||||
}
|
||||
|
||||
describe("DefaultPiPackageService", () => {
|
||||
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
|
||||
const fake = fakeManager([
|
||||
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
|
||||
{ source: "../project-tools", scope: "project", filtered: true },
|
||||
]);
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
await expect(service.list()).resolves.toEqual({
|
||||
packages: [
|
||||
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
|
||||
{ source: "../project-tools", scope: "project", filtered: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("installs through the default Pi package-manager behavior without a local option", async () => {
|
||||
const fake = fakeManager([{ source: "npm:@acme/tools", scope: "user", filtered: false }]);
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
const response = await service.install("npm:@acme/tools");
|
||||
|
||||
expect(fake.installAndPersist).toHaveBeenCalledWith("npm:@acme/tools");
|
||||
expect(response).toEqual({ action: "install", source: "npm:@acme/tools", packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] });
|
||||
});
|
||||
|
||||
it("removes user packages by default and project packages only when the known scope is supplied", async () => {
|
||||
const fake = fakeManager();
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
await service.remove("npm:@acme/user-tools");
|
||||
await service.remove("../project-tools", "project");
|
||||
|
||||
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(1, "npm:@acme/user-tools");
|
||||
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(2, "../project-tools", { local: true });
|
||||
});
|
||||
|
||||
it("updates all configured packages or a single source", async () => {
|
||||
const fake = fakeManager();
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
await service.update();
|
||||
await service.update("npm:@acme/tools");
|
||||
|
||||
expect(fake.update).toHaveBeenNthCalledWith(1);
|
||||
expect(fake.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
|
||||
|
||||
export interface PiPackageManagerPort {
|
||||
listConfiguredPackages(): PiPackageInfo[];
|
||||
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
|
||||
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
|
||||
update(source?: string): Promise<void>;
|
||||
flush?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PiPackageService {
|
||||
list(): Promise<PiPackagesResponse>;
|
||||
install(source: string): Promise<PiPackageMutationResponse>;
|
||||
remove(source: string, scope?: PiPackageScope): Promise<PiPackageMutationResponse>;
|
||||
update(source?: string): Promise<PiPackageMutationResponse>;
|
||||
}
|
||||
|
||||
export class DefaultPiPackageService implements PiPackageService {
|
||||
constructor(private readonly manager: PiPackageManagerPort) {}
|
||||
|
||||
list(): Promise<PiPackagesResponse> {
|
||||
return Promise.resolve({ packages: this.listPackages() });
|
||||
}
|
||||
|
||||
async install(source: string): Promise<PiPackageMutationResponse> {
|
||||
await this.manager.installAndPersist(source);
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("install", { source });
|
||||
}
|
||||
|
||||
async remove(source: string, scope: PiPackageScope = "user"): Promise<PiPackageMutationResponse> {
|
||||
const removed = scope === "project"
|
||||
? await this.manager.removeAndPersist(source, { local: true })
|
||||
: await this.manager.removeAndPersist(source);
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("remove", { source, scope, removed });
|
||||
}
|
||||
|
||||
async update(source?: string): Promise<PiPackageMutationResponse> {
|
||||
if (source === undefined) {
|
||||
await this.manager.update();
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("update", {});
|
||||
}
|
||||
|
||||
await this.manager.update(source);
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("update", { source });
|
||||
}
|
||||
|
||||
private mutationResponse(action: PiPackageMutationAction, metadata: Omit<PiPackageMutationResponse, "action" | "packages">): PiPackageMutationResponse {
|
||||
return { action, ...metadata, packages: this.listPackages() };
|
||||
}
|
||||
|
||||
private async flushSettings(): Promise<void> {
|
||||
await this.manager.flush?.();
|
||||
}
|
||||
|
||||
private listPackages(): PiPackageInfo[] {
|
||||
return this.manager.listConfiguredPackages().map((configuredPackage) => ({
|
||||
source: configuredPackage.source,
|
||||
scope: configuredPackage.scope,
|
||||
filtered: configuredPackage.filtered,
|
||||
...(configuredPackage.installedPath === undefined ? {} : { installedPath: configuredPackage.installedPath }),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService {
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
return new DefaultPiPackageService({
|
||||
listConfiguredPackages: () => manager.listConfiguredPackages(),
|
||||
installAndPersist: (source, options) => manager.installAndPersist(source, options),
|
||||
removeAndPersist: (source, options) => manager.removeAndPersist(source, options),
|
||||
update: (source) => manager.update(source),
|
||||
flush: () => settingsManager.flush(),
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,29 @@ describe("PiWebPluginService", () => {
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
|
||||
});
|
||||
|
||||
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
|
||||
const agentDir = join(tempDir, "agent");
|
||||
const firstPackageDir = join(tempDir, "first-package");
|
||||
const secondPackageDir = join(tempDir, "second-package");
|
||||
await writePlugin(firstPackageDir, {
|
||||
packageJson: { piWeb: { plugins: [{ id: "first", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
await writePlugin(secondPackageDir, {
|
||||
packageJson: { piWeb: { plugins: [{ id: "second", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
await writePiPackageSettings(agentDir, [firstPackageDir]);
|
||||
const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDir });
|
||||
|
||||
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "first" }] });
|
||||
|
||||
await writePiPackageSettings(agentDir, [secondPackageDir]);
|
||||
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("discovers source checkout plugin packages without symlinks", async () => {
|
||||
await mkdir(join(tempDir, "src", "server"), { recursive: true });
|
||||
await writeFile(join(tempDir, "src", "server", "index.ts"), "export {};\n");
|
||||
@@ -189,6 +212,11 @@ describe("PiWebPluginService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function writePiPackageSettings(agentDir: string, packages: string[]): Promise<void> {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writePlugin(root: string, options: { packageJson: unknown; files: Record<string, string> }): Promise<void> {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, "package.json"), `${JSON.stringify(options.packageJson, null, 2)}\n`);
|
||||
|
||||
@@ -69,22 +69,22 @@ interface PiWebPluginEntry {
|
||||
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
||||
|
||||
export class DefaultPiPackageProvider implements PiPackageProvider {
|
||||
private readonly packageManager: DefaultPackageManager;
|
||||
|
||||
constructor(cwd = process.cwd(), agentDir = getAgentDir()) {
|
||||
this.packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: SettingsManager.create(cwd, agentDir),
|
||||
});
|
||||
}
|
||||
constructor(private readonly cwd = process.cwd(), private readonly agentDir = getAgentDir()) {}
|
||||
|
||||
listPackages(): ConfiguredPiPackage[] {
|
||||
return this.packageManager.listConfiguredPackages();
|
||||
return this.createPackageManager().listConfiguredPackages();
|
||||
}
|
||||
|
||||
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
|
||||
return this.packageManager.getInstalledPath(source, scope);
|
||||
return this.createPackageManager().getInstalledPath(source, scope);
|
||||
}
|
||||
|
||||
private createPackageManager(): DefaultPackageManager {
|
||||
return new DefaultPackageManager({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
settingsManager: SettingsManager.create(this.cwd, this.agentDir),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user