feat(FN-1411): implement plugin management API routes with project scoping

- Add GET /api/plugins and GET /api/plugins/:id for listing and retrieving plugins
- Add POST /api/plugins with mode discriminator (register/install) for plugin registration and installation
- Add POST /api/plugins/:id/enable and /disable for plugin lifecycle management
- Add PATCH /api/plugins/:id/settings for updating plugin configuration
- Add DELETE /api/plugins/:id for plugin uninstallation
- All endpoints support projectId scoping via getScopedStore() for multi-project support
- Add comprehensive test suite covering all plugin routes with project context mocking
- Document plugin API endpoints in dashboard README
This commit is contained in:
gsxdsm
2026-04-10 12:34:13 -07:00
parent ed396fc6b6
commit e710963d4a
4 changed files with 1139 additions and 5 deletions

View File

@@ -744,6 +744,53 @@ The dashboard exposes run-audit retrieval and correlation endpoints for inspecti
- `POST /api/auth/login` - Initiate OAuth login
- `POST /api/auth/logout` - Logout from provider
### Plugins
Plugin management endpoints with multi-project scoping support via `projectId` query/body parameter.
#### Plugin Listing
- `GET /api/plugins` - List all installed plugins
- Query: `projectId?` (scope to project), `enabled?` (filter by enabled status)
- Response: `PluginInstallation[]`
- `GET /api/plugins/:id` - Get a single plugin by ID
- Query: `projectId?` (scope to project)
- Response: `PluginInstallation`
- Error: `404` if plugin not found
#### Plugin Registration (mode: register)
- `POST /api/plugins` - Register a new plugin with explicit manifest
- Body: `{ mode: "register", id, name, version, path, description?, author?, homepage?, dependencies?, settingsSchema?, settings? }`
- Query/Body: `projectId?` (scope to project)
- Response: `201` with `PluginInstallation`
- Errors: `400` validation, `409` conflict (already registered)
#### Plugin Installation (mode: install)
- `POST /api/plugins` - Install plugin from local path (loads manifest automatically)
- Body: `{ mode: "install", path }`
- Query/Body: `projectId?` (scope to project)
- Response: `201` with `PluginInstallation`
- Errors: `400` install not supported, `404` manifest not found, `400` invalid manifest, `409` conflict
#### Plugin Lifecycle
- `POST /api/plugins/:id/enable` - Enable and start a plugin
- Body: `{ projectId? }`
- Response: Updated `PluginInstallation`
- `POST /api/plugins/:id/disable` - Disable and stop a plugin
- Body: `{ projectId? }`
- Response: Updated `PluginInstallation`
#### Plugin Settings
- `PATCH /api/plugins/:id/settings` - Update plugin settings
- Body: `{ settings: Record<string, unknown>, projectId? }`
- Response: Updated `PluginInstallation`
- Errors: `400` validation, `404` not found
#### Plugin Uninstall
- `DELETE /api/plugins/:id` - Uninstall a plugin
- Query: `projectId?` (scope to project)
- Response: `204` No Content
## Architecture
- **Frontend**: React + Vite, TypeScript, xterm.js for terminal emulation, CSS custom properties for theming

View File

@@ -0,0 +1,781 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import type { PluginInstallation } from "@fusion/core";
import type { PluginStore } from "@fusion/core";
import type { PluginLoader } from "@fusion/core";
import { createApiRoutes } from "./routes.js";
import { get as performGet, request as performRequest } from "./test-request.js";
import * as projectStoreResolver from "./project-store-resolver.js";
// Mock @fusion/core
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
})),
};
});
// Mock project store resolver
const mockGetOrCreateProjectStore = vi.fn();
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockImplementation(mockGetOrCreateProjectStore);
function createMockPluginStore(overrides: Partial<PluginStore> = {}): PluginStore {
return {
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
registerPlugin: vi.fn(),
unregisterPlugin: vi.fn(),
enablePlugin: vi.fn(),
disablePlugin: vi.fn(),
updatePluginSettings: vi.fn(),
updatePluginState: vi.fn(),
updatePlugin: vi.fn(),
...overrides,
} as unknown as PluginStore;
}
function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLoader {
return {
loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as PluginLoader;
}
function createMockTaskStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn().mockReturnValue({
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
}),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
createWorkflowStep: vi.fn(),
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
getPluginStore: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
const FAKE_PLUGIN: PluginInstallation = {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
description: "A test plugin",
path: "/path/to/plugin",
enabled: true,
state: "installed",
settings: {},
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
const res = await performGet(app, path);
return { status: res.status, body: res.body };
}
async function REQUEST(
app: express.Express,
method: string,
path: string,
body?: unknown,
): Promise<{ status: number; body: any }> {
const res = await performRequest(
app,
method,
path,
body ? JSON.stringify(body) : undefined,
body ? { "content-type": "application/json" } : undefined,
);
return { status: res.status, body: res.body };
}
describe("GET /plugins", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("returns empty array when no plugins", async () => {
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({});
});
it("returns list of plugins", async () => {
(pluginStore.listPlugins as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_PLUGIN]);
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toMatchObject({
id: "test-plugin",
name: "Test Plugin",
});
});
it("filters plugins by enabled status", async () => {
const res = await GET(buildApp(), "/api/plugins?enabled=true");
expect(res.status).toBe(200);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({ enabled: true });
});
it("filters plugins by disabled status", async () => {
const res = await GET(buildApp(), "/api/plugins?enabled=false");
expect(res.status).toBe(200);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({ enabled: false });
});
});
describe("GET /plugins/:id", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("returns plugin by id", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await GET(buildApp(), "/api/plugins/test-plugin");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "test-plugin",
name: "Test Plugin",
});
});
it("returns 404 for non-existent plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await GET(buildApp(), "/api/plugins/nonexistent");
expect(res.status).toBe(404);
expect(res.body).toHaveProperty("error");
});
it("supports projectId query param scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await GET(buildApp(), "/api/plugins/test-plugin?projectId=proj_123");
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("POST /plugins", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader,
}));
return app;
}
describe("mode: register", () => {
it("registers a plugin with required fields", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: "test-plugin",
name: "Test Plugin",
});
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
}),
path: "/path/to/plugin",
}),
);
});
it("registers a plugin with optional fields", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
description: "A test plugin",
author: "Test Author",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
description: "A test plugin",
author: "Test Author",
settings: { apiKey: "secret" },
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({
description: "A test plugin",
author: "Test Author",
}),
settings: { apiKey: "secret" },
}),
);
});
it("loads plugin after registration when enabled", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
enabled: true,
});
// Plugin should be loaded after registration
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("returns 400 when mode is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("mode");
});
it("returns 400 when id is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'id' is required");
});
it("returns 400 when name is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'name' is required");
});
it("returns 400 when version is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'version' is required");
});
it("returns 400 when path is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'path' is required");
});
it("returns 409 when plugin is already registered", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "test-plugin" is already registered'), { code: "EEXISTS" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(409);
});
});
describe("mode: install", () => {
it("returns 400 when plugin loader is not available", async () => {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
// No pluginLoader
}));
const res = await REQUEST(app, "POST", "/api/plugins", {
mode: "install",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("not supported");
});
});
describe("invalid mode", () => {
it("returns 400 for unknown mode", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "unknown",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid mode");
});
});
});
describe("POST /plugins/:id/enable", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader,
}));
return app;
}
it("enables a plugin and loads it", async () => {
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/enable", {});
expect(res.status).toBe(200);
expect(pluginStore.enablePlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports body-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/enable", {
projectId: "proj_123",
});
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("POST /plugins/:id/disable", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader,
}));
return app;
}
it("disables a plugin and stops it", async () => {
(pluginStore.disablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
enabled: false,
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/disable", {});
expect(res.status).toBe(200);
expect(pluginStore.disablePlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginLoader.stopPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports body-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.disablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
enabled: false,
});
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/disable", {
projectId: "proj_123",
});
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("PATCH /plugins/:id/settings", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("updates plugin settings", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
settings: { apiKey: "new-secret" },
});
const res = await REQUEST(buildApp(), "PATCH", "/api/plugins/test-plugin/settings", {
settings: { apiKey: "new-secret" },
});
expect(res.status).toBe(200);
expect(pluginStore.updatePluginSettings).toHaveBeenCalledWith("test-plugin", { apiKey: "new-secret" });
});
it("returns 400 when settings is missing", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/plugins/test-plugin/settings", {});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'settings'");
});
it("returns 404 when plugin not found", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await REQUEST(buildApp(), "PATCH", "/api/plugins/nonexistent/settings", {
settings: { key: "value" },
});
expect(res.status).toBe(404);
});
it("returns 400 when settings validation fails", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Settings validation failed: setting 'apiKey' is required"),
);
const res = await REQUEST(buildApp(), "PATCH", "/api/plugins/test-plugin/settings", {
settings: {},
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("validation failed");
});
});
describe("DELETE /plugins/:id", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader,
}));
return app;
}
it("unregisters a plugin", async () => {
(pluginStore.unregisterPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await REQUEST(buildApp(), "DELETE", "/api/plugins/test-plugin");
expect(res.status).toBe(204);
expect(pluginStore.unregisterPlugin).toHaveBeenCalledWith("test-plugin");
});
it("stops plugin before unregistering", async () => {
(pluginStore.unregisterPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
await REQUEST(buildApp(), "DELETE", "/api/plugins/test-plugin");
// Should stop first, then unregister
expect(pluginLoader.stopPlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginStore.unregisterPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports query-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.unregisterPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "DELETE", "/api/plugins/test-plugin?projectId=proj_123");
expect(res.status).toBe(204);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("Project scoping", () => {
let defaultPluginStore: PluginStore;
let scopedPluginStore: PluginStore;
let store: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
defaultPluginStore = createMockPluginStore({
listPlugins: vi.fn().mockResolvedValue([{ ...FAKE_PLUGIN, id: "default-plugin" }]),
});
scopedPluginStore = createMockPluginStore({
listPlugins: vi.fn().mockResolvedValue([{ ...FAKE_PLUGIN, id: "scoped-plugin" }]),
});
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(defaultPluginStore),
});
scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
// Reset the mock
mockGetOrCreateProjectStore.mockReset();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore: defaultPluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("uses default store without projectId", async () => {
mockGetOrCreateProjectStore.mockResolvedValue(store);
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe("default-plugin");
});
it("uses scoped store with projectId query param", async () => {
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await GET(buildApp(), "/api/plugins?projectId=proj_123");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe("scoped-plugin");
});
it("uses scoped store with projectId in request body", async () => {
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/disable", {
projectId: "proj_123",
});
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});

View File

@@ -28,7 +28,6 @@ import {
type BadgeUrlComponents,
} from "./github-webhooks.js";
import { createMissionRouter } from "./mission-routes.js";
import { createPluginRouter } from "./plugin-routes.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
@@ -11178,10 +11177,311 @@ Output ONLY the prompt text (no markdown, no explanations).`;
router.use("/missions", createMissionRouter(store, options?.missionAutopilot, aiSessionStore));
// ── Plugin Routes ─────────────────────────────────────────────────────────
// Mount plugin routes at /api/plugins
if (options?.pluginStore && options?.pluginLoader) {
router.use("/plugins", createPluginRouter(options.pluginStore, options.pluginLoader, options.pluginRunner));
}
// Plugin management endpoints with projectId scoping support.
// Uses getScopedStore(req) pattern for multi-project support.
// Requires pluginStore in options.
/**
* GET /api/plugins
* List all installed plugins.
* Query: { projectId?: string, enabled?: boolean }
*/
router.get("/plugins", async (req: Request, res: Response) => {
const scopedStore = await getScopedStore(req);
const pluginStore = scopedStore.getPluginStore();
const filter: { enabled?: boolean } = {};
if (req.query.enabled !== undefined) {
filter.enabled = req.query.enabled === "true";
}
const plugins = await pluginStore.listPlugins(filter);
res.json(plugins);
});
/**
* GET /api/plugins/:id
* Get a single plugin by ID.
* Query: { projectId?: string }
*/
router.get("/plugins/:id", async (req: Request, res: Response) => {
const scopedStore = await getScopedStore(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
try {
const plugin = await pluginStore.getPlugin(id);
res.json(plugin);
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("not found")) {
throw notFound(`Plugin "${id}" not found`);
}
throw internalError(err instanceof Error ? err.message : "Unknown error");
}
});
/**
* POST /api/plugins
* Create or register a plugin.
* Requires `mode` discriminator in body:
* - mode: "register" → body must include { id, name, version, path }, optional { enabled, settings, projectId }
* - mode: "install" → body must include { path }, optional { projectId }
* Returns 201 on success, 400 for validation errors, 409 for conflicts.
*/
router.post("/plugins", async (req: Request, res: Response) => {
const scopedStore = await getScopedStore(req);
const pluginStore = scopedStore.getPluginStore();
if (!req.body || typeof req.body !== "object") {
throw badRequest("Request body is required");
}
const body = req.body as Record<string, unknown>;
// Validate mode discriminator is present
if (!("mode" in body) || typeof body.mode !== "string") {
throw badRequest("Request body must have a 'mode' field with value 'register' or 'install'");
}
const mode = body.mode as string;
if (mode === "register") {
// Register mode: requires id, name, version, path
if (typeof body.id !== "string" || !body.id.trim()) {
throw badRequest("'id' is required for register mode and must be a non-empty string");
}
if (typeof body.name !== "string" || !body.name.trim()) {
throw badRequest("'name' is required for register mode and must be a non-empty string");
}
if (typeof body.version !== "string" || !body.version.trim()) {
throw badRequest("'version' is required for register mode and must be a non-empty string");
}
if (typeof body.path !== "string" || !body.path.trim()) {
throw badRequest("'path' is required for register mode and must be a non-empty string");
}
const manifest: import("@fusion/core").PluginManifest = {
id: body.id as string,
name: body.name as string,
version: body.version as string,
description: typeof body.description === "string" ? body.description : undefined,
author: typeof body.author === "string" ? body.author : undefined,
homepage: typeof body.homepage === "string" ? body.homepage : undefined,
dependencies: Array.isArray(body.dependencies) ? (body.dependencies as string[]) : undefined,
settingsSchema: typeof body.settingsSchema === "object" && body.settingsSchema !== null
? (body.settingsSchema as Record<string, import("@fusion/core").PluginSettingSchema>)
: undefined,
};
const settings = typeof body.settings === "object" && body.settings !== null
? (body.settings as Record<string, unknown>)
: undefined;
// If enabled and loader is available, try to load the plugin
let plugin: import("@fusion/core").PluginInstallation;
try {
plugin = await pluginStore.registerPlugin({
manifest,
path: body.path as string,
settings,
});
if (plugin.enabled && options?.pluginLoader) {
try {
await options.pluginLoader.loadPlugin(plugin.id);
} catch (loadErr) {
// Log but don't fail - plugin is registered, just not loaded
console.error(`[plugin-routes] Failed to load plugin ${plugin.id}:`, loadErr);
}
}
res.status(201).json(plugin);
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("already registered")) {
throw conflict(err.message);
}
throw internalError(err instanceof Error ? err.message : "Failed to register plugin");
}
} else if (mode === "install") {
// Install mode: requires path, loads manifest from path
if (typeof body.path !== "string" || !body.path.trim()) {
throw badRequest("'path' is required for install mode and must be a non-empty string");
}
// Check if runtime install interface is available
if (!options?.pluginLoader) {
throw badRequest("Plugin install mode is not supported: plugin loader not available");
}
const { existsSync } = await import("node:fs");
const { join: pathJoin } = await import("node:path");
const { readFile } = await import("node:fs/promises");
const { validatePluginManifest } = await import("@fusion/core");
const installPath = body.path as string;
const manifestPath = pathJoin(installPath, "manifest.json");
if (!existsSync(manifestPath)) {
throw notFound(`Plugin manifest not found at: ${manifestPath}`);
}
let manifestContent: string;
try {
manifestContent = await readFile(manifestPath, "utf-8");
} catch (readErr) {
throw internalError(`Failed to read manifest: ${readErr instanceof Error ? readErr.message : "Unknown error"}`);
}
let manifest: import("@fusion/core").PluginManifest;
try {
manifest = JSON.parse(manifestContent);
} catch {
throw badRequest("Plugin manifest is not valid JSON");
}
// Validate manifest
const validation = validatePluginManifest(manifest);
if (!validation.valid) {
throw badRequest(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
}
try {
const plugin = await pluginStore.registerPlugin({
manifest,
path: installPath,
});
// If enabled, try to load the plugin
if (plugin.enabled) {
try {
await options.pluginLoader.loadPlugin(plugin.id);
} catch (loadErr) {
console.error(`[plugin-routes] Failed to load plugin ${plugin.id}:`, loadErr);
}
}
res.status(201).json(plugin);
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("already registered")) {
throw conflict(err.message);
}
throw internalError(err instanceof Error ? err.message : "Failed to register plugin");
}
} else {
throw badRequest(`Invalid mode: '${mode}'. Must be 'register' or 'install'`);
}
});
/**
* POST /api/plugins/:id/enable
* Enable a plugin and start it.
* Body: { projectId?: string }
*/
router.post("/plugins/:id/enable", async (req: Request, res: Response) => {
const scopedStore = await getScopedStore(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
let plugin = await pluginStore.enablePlugin(id);
// Start the plugin if loader is available
if (options?.pluginLoader) {
try {
await options.pluginLoader.loadPlugin(id);
} catch (loadErr) {
// Update state to error
await pluginStore.updatePluginState(
id,
"error",
loadErr instanceof Error ? loadErr.message : String(loadErr),
);
plugin = await pluginStore.getPlugin(id);
}
}
res.json(plugin);
});
/**
* POST /api/plugins/:id/disable
* Disable a plugin and stop it.
* Body: { projectId?: string }
*/
router.post("/plugins/:id/disable", async (req: Request, res: Response) => {
const scopedStore = await getScopedStore(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
// Stop the plugin if loader is available
if (options?.pluginLoader) {
try {
await options.pluginLoader.stopPlugin(id);
} catch {
// Ignore errors from stopping - plugin might not be loaded
}
}
const plugin = await pluginStore.disablePlugin(id);
res.json(plugin);
});
/**
* PATCH /api/plugins/:id/settings
* Update plugin settings.
* Body: { settings: Record<string, unknown>, projectId?: string }
*/
router.patch("/plugins/:id/settings", async (req: Request, res: Response) => {
const scopedStore = await getScopedStore(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
if (!req.body || typeof req.body !== "object") {
throw badRequest("Request body must be an object with 'settings' field");
}
const body = req.body as Record<string, unknown>;
const settings = body.settings as Record<string, unknown> | undefined;
if (!settings || typeof settings !== "object") {
throw badRequest("Request body must have a 'settings' object");
}
try {
const plugin = await pluginStore.updatePluginSettings(id, settings);
res.json(plugin);
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("not found")) {
throw notFound(`Plugin "${id}" not found`);
}
if (err instanceof Error && err.message.includes("validation failed")) {
throw badRequest(err.message);
}
throw internalError(err instanceof Error ? err.message : "Failed to update settings");
}
});
/**
* DELETE /api/plugins/:id
* Uninstall a plugin.
* Query: { projectId?: string }
*/
router.delete("/plugins/:id", async (req: Request, res: Response) => {
const scopedStore = await getScopedStore(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
// Stop the plugin if loader is available
if (options?.pluginLoader) {
try {
await options.pluginLoader.stopPlugin(id);
} catch {
// Ignore - plugin might not be loaded
}
}
await pluginStore.unregisterPlugin(id);
res.status(204).send();
});
// ── AI Session Routes (Background Tasks) ─────────────────────────────────