feat(FN-1944): add Pi settings package management flow
- Add dashboard backend routes for Pi settings management with comprehensive route tests - Add API client functions and tests for fetching and updating Pi settings endpoints - Replace PiExtensionsManager with package management UI and add supporting dashboard styles - Rewrite and fix PiExtensionsManager tests and include a changeset for @gsxdsm/fusion
This commit is contained in:
300
packages/dashboard/src/__tests__/pi-extensions-routes.test.ts
Normal file
300
packages/dashboard/src/__tests__/pi-extensions-routes.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { request } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
// Mock the pi-coding-agent module for all route tests
|
||||
const mockSettingsManager = {
|
||||
getPackages: vi.fn().mockReturnValue(["npm:pi-example"]),
|
||||
getExtensionPaths: vi.fn().mockReturnValue(["/path/to/extension"]),
|
||||
getSkillPaths: vi.fn().mockReturnValue(["/path/to/skill"]),
|
||||
getPromptTemplatePaths: vi.fn().mockReturnValue(["/path/to/prompts"]),
|
||||
getThemePaths: vi.fn().mockReturnValue(["/path/to/themes"]),
|
||||
setPackages: vi.fn(),
|
||||
setExtensionPaths: vi.fn(),
|
||||
setSkillPaths: vi.fn(),
|
||||
setPromptTemplatePaths: vi.fn(),
|
||||
setThemePaths: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
SettingsManager: {
|
||||
create: vi.fn(() => mockSettingsManager),
|
||||
},
|
||||
getAgentDir: vi.fn(() => "/fake/agent/dir"),
|
||||
DefaultPackageManager: vi.fn().mockImplementation(() => ({
|
||||
install: vi.fn().mockResolvedValue(undefined),
|
||||
addSourceToSettings: vi.fn().mockReturnValue(true),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Minimal store implementation for the test server
|
||||
class MinimalStore {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-1944";
|
||||
}
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-1944/.fusion";
|
||||
}
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { "Content-Type": "application/json" };
|
||||
|
||||
describe("Pi settings routes", () => {
|
||||
const app = createServer(new MinimalStore() as any);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset default mock returns
|
||||
mockSettingsManager.getPackages.mockReturnValue(["npm:pi-example"]);
|
||||
mockSettingsManager.getExtensionPaths.mockReturnValue(["/path/to/extension"]);
|
||||
mockSettingsManager.getSkillPaths.mockReturnValue(["/path/to/skill"]);
|
||||
mockSettingsManager.getPromptTemplatePaths.mockReturnValue(["/path/to/prompts"]);
|
||||
mockSettingsManager.getThemePaths.mockReturnValue(["/path/to/themes"]);
|
||||
mockSettingsManager.flush.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("GET /api/pi-settings", () => {
|
||||
it("returns pi settings from SettingsManager", async () => {
|
||||
mockSettingsManager.getPackages.mockReturnValue(["npm:pi-example", "git:https://github.com/user/repo.git"]);
|
||||
mockSettingsManager.getExtensionPaths.mockReturnValue(["/custom/ext"]);
|
||||
mockSettingsManager.getSkillPaths.mockReturnValue(["/custom/skill"]);
|
||||
mockSettingsManager.getPromptTemplatePaths.mockReturnValue(["/custom/prompts"]);
|
||||
mockSettingsManager.getThemePaths.mockReturnValue(["/custom/themes"]);
|
||||
|
||||
const res = await request(app, "GET", "/api/pi-settings");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
packages: ["npm:pi-example", "git:https://github.com/user/repo.git"],
|
||||
extensions: ["/custom/ext"],
|
||||
skills: ["/custom/skill"],
|
||||
prompts: ["/custom/prompts"],
|
||||
themes: ["/custom/themes"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty arrays when no settings configured", async () => {
|
||||
mockSettingsManager.getPackages.mockReturnValue([]);
|
||||
mockSettingsManager.getExtensionPaths.mockReturnValue([]);
|
||||
mockSettingsManager.getSkillPaths.mockReturnValue([]);
|
||||
mockSettingsManager.getPromptTemplatePaths.mockReturnValue([]);
|
||||
mockSettingsManager.getThemePaths.mockReturnValue([]);
|
||||
|
||||
const res = await request(app, "GET", "/api/pi-settings");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
packages: [],
|
||||
extensions: [],
|
||||
skills: [],
|
||||
prompts: [],
|
||||
themes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 500 when SettingsManager throws", async () => {
|
||||
mockSettingsManager.getPackages.mockImplementation(() => {
|
||||
throw new Error("Failed to read settings");
|
||||
});
|
||||
|
||||
const res = await request(app, "GET", "/api/pi-settings");
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Failed to read settings" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/pi-settings", () => {
|
||||
it("updates packages and returns success", async () => {
|
||||
const res = await request(app, "PUT", "/api/pi-settings",
|
||||
JSON.stringify({ packages: ["npm:new-package", "git:https://github.com/new/repo.git"] }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(mockSettingsManager.setPackages).toHaveBeenCalledWith([
|
||||
"npm:new-package",
|
||||
"git:https://github.com/new/repo.git",
|
||||
]);
|
||||
expect(mockSettingsManager.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates extensions and returns success", async () => {
|
||||
const res = await request(app, "PUT", "/api/pi-settings",
|
||||
JSON.stringify({ extensions: ["/new/extension/path"] }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSettingsManager.setExtensionPaths).toHaveBeenCalledWith(["/new/extension/path"]);
|
||||
expect(mockSettingsManager.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates multiple fields at once", async () => {
|
||||
const res = await request(app, "PUT", "/api/pi-settings",
|
||||
JSON.stringify({
|
||||
packages: ["npm:example"],
|
||||
extensions: ["/custom/ext"],
|
||||
skills: ["/custom/skill"],
|
||||
prompts: ["/custom/prompts"],
|
||||
themes: ["/custom/themes"],
|
||||
}),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSettingsManager.setPackages).toHaveBeenCalledWith(["npm:example"]);
|
||||
expect(mockSettingsManager.setExtensionPaths).toHaveBeenCalledWith(["/custom/ext"]);
|
||||
expect(mockSettingsManager.setSkillPaths).toHaveBeenCalledWith(["/custom/skill"]);
|
||||
expect(mockSettingsManager.setPromptTemplatePaths).toHaveBeenCalledWith(["/custom/prompts"]);
|
||||
expect(mockSettingsManager.setThemePaths).toHaveBeenCalledWith(["/custom/themes"]);
|
||||
expect(mockSettingsManager.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when body is empty object", async () => {
|
||||
const res = await request(app, "PUT", "/api/pi-settings", JSON.stringify({}), JSON_HEADERS);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: "At least one setting field must be provided (packages, extensions, skills, prompts, or themes)" });
|
||||
});
|
||||
|
||||
it("returns error when body is undefined (no JSON body)", async () => {
|
||||
// Sending no body with no Content-Type: no body parser runs, req.body is undefined
|
||||
const res = await request(app, "PUT", "/api/pi-settings", undefined);
|
||||
|
||||
// Without a JSON body, the route throws because all fields are undefined
|
||||
// Either 400 (badRequest) or 500 depending on how the body parser handles empty PUT
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(600);
|
||||
});
|
||||
|
||||
it("returns 400 when packages is not an array", async () => {
|
||||
const res = await request(app, "PUT", "/api/pi-settings",
|
||||
JSON.stringify({ packages: "not-an-array" }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: "packages must be an array" });
|
||||
});
|
||||
|
||||
it("returns 400 when extensions is not an array", async () => {
|
||||
const res = await request(app, "PUT", "/api/pi-settings",
|
||||
JSON.stringify({ extensions: "not-an-array" }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: "extensions must be an array of strings" });
|
||||
});
|
||||
|
||||
it("returns 400 when skills is not an array", async () => {
|
||||
const res = await request(app, "PUT", "/api/pi-settings",
|
||||
JSON.stringify({ skills: 123 }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: "skills must be an array of strings" });
|
||||
});
|
||||
|
||||
it("returns 500 when flush throws", async () => {
|
||||
mockSettingsManager.flush.mockRejectedValueOnce(new Error("Write failed"));
|
||||
|
||||
const res = await request(app, "PUT", "/api/pi-settings",
|
||||
JSON.stringify({ packages: ["npm:new-package"] }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/pi-settings/packages", () => {
|
||||
it("installs package and returns success", async () => {
|
||||
const res = await request(app, "POST", "/api/pi-settings/packages",
|
||||
JSON.stringify({ source: "npm:pi-new-extension" }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("installs git package source", async () => {
|
||||
const res = await request(app, "POST", "/api/pi-settings/packages",
|
||||
JSON.stringify({ source: "git:https://github.com/example/extension.git" }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("returns 400 when source is empty", async () => {
|
||||
const res = await request(app, "POST", "/api/pi-settings/packages",
|
||||
JSON.stringify({ source: "" }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: "source must be a non-empty string" });
|
||||
});
|
||||
|
||||
it("returns 400 when source is whitespace only", async () => {
|
||||
const res = await request(app, "POST", "/api/pi-settings/packages",
|
||||
JSON.stringify({ source: " " }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: "source must be a non-empty string" });
|
||||
});
|
||||
|
||||
it("returns 400 when source is missing", async () => {
|
||||
const res = await request(app, "POST", "/api/pi-settings/packages", JSON.stringify({}), JSON_HEADERS);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: "source must be a non-empty string" });
|
||||
});
|
||||
|
||||
it("returns 500 when install throws", async () => {
|
||||
const { DefaultPackageManager } = await import("@mariozechner/pi-coding-agent");
|
||||
vi.mocked(DefaultPackageManager).mockImplementationOnce(() => ({
|
||||
install: vi.fn().mockRejectedValue(new Error("Install failed")),
|
||||
addSourceToSettings: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const res = await request(app, "POST", "/api/pi-settings/packages",
|
||||
JSON.stringify({ source: "npm:failing-package" }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns success when addSourceToSettings returns false (already configured)", async () => {
|
||||
const { DefaultPackageManager } = await import("@mariozechner/pi-coding-agent");
|
||||
vi.mocked(DefaultPackageManager).mockImplementationOnce(() => ({
|
||||
install: vi.fn().mockResolvedValue(undefined),
|
||||
addSourceToSettings: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
const res = await request(app, "POST", "/api/pi-settings/packages",
|
||||
JSON.stringify({ source: "npm:already-configured" }),
|
||||
JSON_HEADERS
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3350,6 +3350,134 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Pi Settings Routes ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/pi-settings
|
||||
* Returns the user's global pi extension settings from ~/.pi/agent/settings.json.
|
||||
* Includes packages, extension paths, skill paths, prompt template paths, and theme paths.
|
||||
*/
|
||||
router.get("/pi-settings", async (_req, res) => {
|
||||
try {
|
||||
const { SettingsManager, getAgentDir } = await import("@mariozechner/pi-coding-agent");
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.create(undefined, agentDir);
|
||||
const packages = settingsManager.getPackages();
|
||||
const extensions = settingsManager.getExtensionPaths();
|
||||
const skills = settingsManager.getSkillPaths();
|
||||
const prompts = settingsManager.getPromptTemplatePaths();
|
||||
const themes = settingsManager.getThemePaths();
|
||||
res.json({ packages, extensions, skills, prompts, themes });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/pi-settings
|
||||
* Updates the user's global pi extension settings in ~/.pi/agent/settings.json.
|
||||
* Accepts partial updates: only provided fields are updated.
|
||||
* Body: { packages?: PackageSource[], extensions?: string[], skills?: string[], prompts?: string[], themes?: string[] }
|
||||
*/
|
||||
router.put("/pi-settings", async (req, res) => {
|
||||
try {
|
||||
const { packages, extensions, skills, prompts, themes } = req.body as {
|
||||
packages?: unknown;
|
||||
extensions?: unknown;
|
||||
skills?: unknown;
|
||||
prompts?: unknown;
|
||||
themes?: unknown;
|
||||
};
|
||||
|
||||
// Validate that at least one field is provided
|
||||
if (packages === undefined && extensions === undefined && skills === undefined && prompts === undefined && themes === undefined) {
|
||||
throw badRequest("At least one setting field must be provided (packages, extensions, skills, prompts, or themes)");
|
||||
}
|
||||
|
||||
const { SettingsManager, getAgentDir } = await import("@mariozechner/pi-coding-agent");
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.create(undefined, agentDir);
|
||||
|
||||
if (packages !== undefined) {
|
||||
if (!Array.isArray(packages)) {
|
||||
throw badRequest("packages must be an array");
|
||||
}
|
||||
settingsManager.setPackages(packages as string[]);
|
||||
}
|
||||
if (extensions !== undefined) {
|
||||
if (!Array.isArray(extensions)) {
|
||||
throw badRequest("extensions must be an array of strings");
|
||||
}
|
||||
settingsManager.setExtensionPaths(extensions as string[]);
|
||||
}
|
||||
if (skills !== undefined) {
|
||||
if (!Array.isArray(skills)) {
|
||||
throw badRequest("skills must be an array of strings");
|
||||
}
|
||||
settingsManager.setSkillPaths(skills as string[]);
|
||||
}
|
||||
if (prompts !== undefined) {
|
||||
if (!Array.isArray(prompts)) {
|
||||
throw badRequest("prompts must be an array of strings");
|
||||
}
|
||||
settingsManager.setPromptTemplatePaths(prompts as string[]);
|
||||
}
|
||||
if (themes !== undefined) {
|
||||
if (!Array.isArray(themes)) {
|
||||
throw badRequest("themes must be an array of strings");
|
||||
}
|
||||
settingsManager.setThemePaths(themes as string[]);
|
||||
}
|
||||
|
||||
await settingsManager.flush();
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pi-settings/packages
|
||||
* Installs a new pi package source and adds it to the global settings.
|
||||
* Body: { source: string }
|
||||
*/
|
||||
router.post("/pi-settings/packages", async (req, res) => {
|
||||
try {
|
||||
const { source } = req.body as { source?: unknown };
|
||||
if (typeof source !== "string" || !source.trim()) {
|
||||
throw badRequest("source must be a non-empty string");
|
||||
}
|
||||
|
||||
const { SettingsManager, DefaultPackageManager, getAgentDir } = await import("@mariozechner/pi-coding-agent");
|
||||
const agentDir = getAgentDir();
|
||||
const cwd = process.cwd();
|
||||
const settingsManager = SettingsManager.create(undefined, agentDir);
|
||||
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
|
||||
await packageManager.install(source.trim());
|
||||
const added = packageManager.addSourceToSettings(source.trim());
|
||||
if (!added) {
|
||||
// Already in settings (setPackages deduplicates), treat as success
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await settingsManager.flush();
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/test-ntfy
|
||||
* Send a test notification to verify ntfy configuration.
|
||||
|
||||
Reference in New Issue
Block a user