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:
Fusion
2026-04-18 06:31:41 -07:00
committed by gsxdsm
parent 2ec668059a
commit 2fd586f6d0
8 changed files with 1357 additions and 265 deletions

View 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 });
});
});
});

View File

@@ -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.