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:
@@ -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
|
||||
|
||||
781
packages/dashboard/src/plugin-routes.routes.test.ts
Normal file
781
packages/dashboard/src/plugin-routes.routes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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) ─────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user