feat(FN-1493): add directory browsing UX to plugin install flow

- Add DirectoryPicker component for browsing filesystem to select plugin source
- Add dist-folder and package-root install-source resolution in backend
- Wire frontend installPlugin to mode-discriminated endpoint (/plugins/install-local)
- Add comprehensive regression tests for browse-driven plugin install workflow
- Update PluginManager UI with directory picker integration and CSS styles
This commit is contained in:
gsxdsm
2026-04-12 18:10:39 -07:00
parent 9d2f61501b
commit 717124fc9e
10 changed files with 1318 additions and 119 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Plugin install from directory browser now resolves manifest.json from dist folders and package roots. Selecting a package root probes `dist/manifest.json`; selecting a dist folder probes the parent for `manifest.json`. Path validation enforces absolute paths and rejects traversal sequences.

View File

@@ -4627,9 +4627,9 @@ export async function installPlugin(
source: { path: string } | { package: string },
projectId?: string,
): Promise<PluginInstallation> {
return api<PluginInstallation>(withProjectId("/plugins/install", projectId), {
return api<PluginInstallation>(withProjectId("/plugins", projectId), {
method: "POST",
body: JSON.stringify(source),
body: JSON.stringify({ mode: "install", ...source }),
});
}

View File

@@ -6,6 +6,8 @@ export interface DirectoryPickerProps {
value: string;
onChange: (path: string) => void;
placeholder?: string;
/** Optional keydown handler forwarded to the text input (e.g. Enter-to-submit). */
onInputKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}
interface BrowserState {
@@ -18,7 +20,7 @@ interface BrowserState {
showHidden: boolean;
}
export function DirectoryPicker({ value, onChange, placeholder }: DirectoryPickerProps) {
export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown }: DirectoryPickerProps) {
const [browser, setBrowser] = useState<BrowserState>({
isOpen: false,
loading: false,
@@ -105,6 +107,7 @@ export function DirectoryPicker({ value, onChange, placeholder }: DirectoryPicke
className="directory-picker-input"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onInputKeyDown}
placeholder={placeholder || "/path/to/your/project"}
/>
<button

View File

@@ -13,6 +13,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw } from "lucide-react";
import { fetchPlugins, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin } from "../api";
import { DirectoryPicker } from "./DirectoryPicker";
import type { PluginInstallation, PluginState } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
@@ -429,18 +430,25 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
{showInstall && (
<div className="plugin-install-form">
<input
type="text"
placeholder="Local path to plugin directory"
<p className="plugin-install-hint">
Browse to a plugin package root (contains <code>manifest.json</code>) or a built <code>dist</code> directory.
</p>
<DirectoryPicker
value={installPath}
onChange={(e) => setInstallPath(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleInstall()}
onChange={setInstallPath}
placeholder="Absolute path to plugin directory or dist folder"
onInputKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleInstall();
}
}}
/>
<div className="plugin-install-actions">
<button className="btn-primary" onClick={handleInstall} disabled={installing}>
{installing ? "Installing..." : "Install"}
<button className="btn-primary" onClick={handleInstall} disabled={installing || !installPath.trim()}>
{installing ? "Installing..." : "Install Plugin"}
</button>
<button className="btn-secondary" onClick={() => setShowInstall(false)}>
<button className="btn-secondary" onClick={() => { setShowInstall(false); setInstallPath(""); }}>
Cancel
</button>
</div>

View File

@@ -0,0 +1,301 @@
/**
* Regression tests for the browse-driven plugin install workflow.
*
* Verifies that:
* 1. DirectoryPicker selection updates the install source field
* 2. Install sends the expected { path } payload
* 3. Negative paths: empty path, install failure
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, within, fireEvent, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { PluginInstallation } from "@fusion/core";
// ── Mock API ──────────────────────────────────────────────────────
vi.mock("../../api", () => ({
fetchPlugins: vi.fn(() => Promise.resolve([])),
installPlugin: vi.fn(() =>
Promise.resolve({
id: "browsed-plugin",
name: "Browsed Plugin",
version: "0.1.0",
state: "installed" as const,
enabled: true,
settings: {},
settingsSchema: {},
}),
),
enablePlugin: vi.fn(() => Promise.resolve({})),
disablePlugin: vi.fn(() => Promise.resolve({})),
uninstallPlugin: vi.fn(() => Promise.resolve()),
fetchPluginSettings: vi.fn(() => Promise.resolve({})),
updatePluginSettings: vi.fn(() => Promise.resolve({})),
reloadPlugin: vi.fn(() => Promise.resolve({})),
browseDirectory: vi.fn(() =>
Promise.resolve({
currentPath: "/home/user/plugins/my-plugin",
parentPath: "/home/user/plugins",
entries: [
{ name: "dist", path: "/home/user/plugins/my-plugin/dist", hasChildren: true },
{ name: "src", path: "/home/user/plugins/my-plugin/src", hasChildren: true },
],
}),
),
}));
import { PluginManager } from "../PluginManager";
import {
fetchPlugins,
installPlugin,
browseDirectory,
} from "../../api";
const addToast = vi.fn();
// ── Shared setup ──────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
// Stub EventSource globally
const esInstance = {
url: "",
readyState: 1,
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
onerror: null,
onopen: null,
onmessage: null,
};
const MockES = vi.fn(() => esInstance) as unknown as typeof EventSource;
MockES.CONNECTING = 0;
MockES.OPEN = 1;
MockES.CLOSED = 2;
vi.stubGlobal("EventSource", MockES);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ── Helpers ───────────────────────────────────────────────────────
/** Open the install form by clicking the header Install button. */
async function openInstallForm() {
const installBtn = screen.getByRole("button", { name: /^Install$/ });
await userEvent.click(installBtn);
}
/** Return the DirectoryPicker input inside the install form. */
function getPathInput(): HTMLInputElement {
return screen.getByPlaceholderText(
"Absolute path to plugin directory or dist folder",
) as HTMLInputElement;
}
// ══════════════════════════════════════════════════════════════════
describe("PluginManager – browse-driven install workflow", () => {
it("opens DirectoryPicker browser and selects a directory to set install path", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Click "Browse" to open the directory browser
const browseBtn = screen.getByRole("button", {
name: /Browse directories/i,
});
await userEvent.click(browseBtn);
// browseDirectory should have been called
await waitFor(() => expect(browseDirectory).toHaveBeenCalled());
// The browser shows the "Select" button
const selectBtn = screen.getByRole("button", { name: /^Select$/i });
// Clicking Select should copy the currentPath into the input
await userEvent.click(selectBtn);
// The input value should now match the browsed directory
expect(getPathInput().value).toBe("/home/user/plugins/my-plugin");
});
it("sends { path } payload matching the browsed path on install", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Simulate browse-and-select by typing a path (DirectoryPicker onChange)
const input = getPathInput();
await userEvent.type(input, "/home/user/plugins/my-plugin");
// Click Install Plugin
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith(
{ path: "/home/user/plugins/my-plugin" },
undefined,
);
});
});
it("sends { path } with dist subfolder when user navigates into dist", async () => {
// Override browseDirectory to return a dist-level path
vi.mocked(browseDirectory).mockResolvedValueOnce({
currentPath: "/home/user/plugins/my-plugin/dist",
parentPath: "/home/user/plugins/my-plugin",
entries: [],
});
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Directly type a dist path (mimicking what Select would do)
const input = getPathInput();
await userEvent.type(input, "/home/user/plugins/my-plugin/dist");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith(
{ path: "/home/user/plugins/my-plugin/dist" },
undefined,
);
});
});
it("passes projectId when provided", async () => {
render(<PluginManager addToast={addToast} projectId="proj-xyz" />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalledWith("proj-xyz"));
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/plugins/example");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith(
{ path: "/plugins/example" },
"proj-xyz",
);
});
});
// ── Negative paths ────────────────────────────────────────────
it("shows error toast when install path is empty", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Install Plugin button should be disabled with empty input
const formContainer = getPathInput().closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
expect(installBtn).toBeDisabled();
// installPlugin should NOT have been called
expect(installPlugin).not.toHaveBeenCalled();
});
it("shows error toast when installPlugin rejects", async () => {
vi.mocked(installPlugin).mockRejectedValueOnce(
new Error("manifest.json not found"),
);
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/invalid/path");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("manifest.json not found"),
"error",
);
});
});
it("resets install path and hides form on successful install", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/path/to/plugin");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
"Plugin installed successfully",
"success",
);
});
// Install form should be gone
expect(
screen.queryByPlaceholderText(
"Absolute path to plugin directory or dist folder",
),
).toBeNull();
});
it("cancels install form and clears path", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/some/path");
const cancelBtn = screen.getByRole("button", { name: /Cancel/i });
await userEvent.click(cancelBtn);
// Form should be gone
expect(
screen.queryByPlaceholderText(
"Absolute path to plugin directory or dist folder",
),
).toBeNull();
// Re-opening should show empty input
await openInstallForm();
expect(getPathInput().value).toBe("");
});
});

View File

@@ -73,6 +73,11 @@ vi.mock("../../api", () => ({
settings: {},
settingsSchema: {},
})),
browseDirectory: vi.fn(() => Promise.resolve({
currentPath: "/home",
parentPath: "/",
entries: [{ name: "plugins", path: "/home/plugins", hasChildren: true }],
})),
}));
// Import after vi.mock so the mock is in place
@@ -175,7 +180,7 @@ describe("PluginManager", () => {
});
expect(screen.getByText("No plugins installed.")).toBeTruthy();
expect(screen.getByRole("button", { name: /Install$/ })).toBeTruthy();
expect(screen.getByRole("button", { name: /^Install$/ })).toBeTruthy();
});
it("renders plugin list when plugins are available", async () => {
@@ -192,17 +197,24 @@ describe("PluginManager", () => {
expect(screen.getByText("v2.0.0")).toBeTruthy();
});
it("shows install form when Install button is clicked", async () => {
it("shows install form with directory picker and hint when Install button is clicked", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
const installButton = screen.getByRole("button", { name: /Install$/ });
const installButton = screen.getByRole("button", { name: /^Install$/ });
await userEvent.click(installButton);
expect(screen.getByPlaceholderText("Local path to plugin directory")).toBeTruthy();
// Directory picker input is present
expect(screen.getByPlaceholderText("Absolute path to plugin directory or dist folder")).toBeTruthy();
// Browse button from DirectoryPicker is present
expect(screen.getByRole("button", { name: /Browse directories|Close directory browser/i })).toBeTruthy();
// Hint text about valid selections
expect(screen.getByText(/manifest\.json/)).toBeTruthy();
expect(screen.getByText(/dist/)).toBeTruthy();
// Cancel button
expect(screen.getByRole("button", { name: /Cancel/i })).toBeTruthy();
});
@@ -214,16 +226,16 @@ describe("PluginManager", () => {
});
// Click the header Install button
const headerInstallButton = screen.getByRole("button", { name: /Install$/ });
const headerInstallButton = screen.getByRole("button", { name: /^Install$/ });
await userEvent.click(headerInstallButton);
const input = screen.getByPlaceholderText("Local path to plugin directory");
const input = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder");
await userEvent.type(input, "/path/to/plugin");
// Get the form container and find the Install button within it
const formContainer = screen.getByPlaceholderText("Local path to plugin directory").closest(".plugin-install-form");
// Get the form container and find the Install Plugin button within it
const formContainer = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder").closest(".plugin-install-form");
expect(formContainer).toBeTruthy();
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install$/ });
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install Plugin/ });
await userEvent.click(formInstallButton);
await waitFor(() => {
@@ -240,15 +252,15 @@ describe("PluginManager", () => {
expect(fetchPlugins).toHaveBeenCalled();
});
const headerInstallButton = screen.getByRole("button", { name: /Install$/ });
const headerInstallButton = screen.getByRole("button", { name: /^Install$/ });
await userEvent.click(headerInstallButton);
const input = screen.getByPlaceholderText("Local path to plugin directory");
const input = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder");
await userEvent.type(input, "/path/to/plugin");
const formContainer = screen.getByPlaceholderText("Local path to plugin directory").closest(".plugin-install-form");
const formContainer = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder").closest(".plugin-install-form");
expect(formContainer).toBeTruthy();
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install$/ });
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install Plugin/ });
await userEvent.click(formInstallButton);
await waitFor(() => {

View File

@@ -28362,3 +28362,157 @@ html .column.drag-over * {
max-height: 300px;
}
}
/* ── Plugin Manager ─────────────────────────────────────────── */
.plugin-manager,
.plugin-manager-detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-manager-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.plugin-manager-header h3 {
margin: 0;
}
.plugin-manager-actions {
display: flex;
gap: 8px;
align-items: center;
}
.plugin-install-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-secondary, var(--card-bg));
}
.plugin-install-hint {
margin: 0;
font-size: 0.82rem;
color: var(--text-secondary, var(--text-muted));
line-height: 1.45;
}
.plugin-install-hint code {
padding: 1px 5px;
border-radius: 4px;
background: var(--bg-tertiary, rgba(127, 127, 127, 0.12));
font-size: 0.82em;
}
.plugin-install-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.plugin-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.plugin-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 6px;
transition: background 0.15s;
}
.plugin-item:hover {
background: var(--bg-secondary, rgba(127, 127, 127, 0.08));
}
.plugin-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.plugin-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plugin-version {
font-size: 0.82rem;
}
.plugin-state-badge {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.plugin-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* Detail view */
.plugin-manager-detail-header {
display: flex;
align-items: center;
gap: 8px;
}
.plugin-manager-detail-header h3 {
margin: 0;
flex: 1;
}
.plugin-detail-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.plugin-detail-meta {
font-size: 0.9rem;
}
.plugin-detail-meta p {
margin: 4px 0;
}
.plugin-description {
color: var(--text-secondary, var(--text-muted));
}
.plugin-detail-section h4 {
margin: 0 0 8px;
}
.plugin-settings-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.plugin-detail-actions {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}

View File

@@ -0,0 +1,659 @@
/**
* Route-level regression tests for plugin install mode.
*
* Covers:
* - POST /api/plugins mode:"install" with package-root path (manifest.json present)
* - POST /api/plugins mode:"install" with dist-folder path (manifest.json present)
* - Negative: missing manifest.json
* - Negative: invalid JSON manifest
* - Negative: manifest missing required fields
* - Negative: empty / missing path
* - Negative: missing mode discriminator
*/
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import type { TaskStore, PluginStore, PluginLoader, PluginInstallation } 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 node:fs (used by install mode) ──────────────────────────
const mockExistsSync = vi.fn<(p: string) => boolean>().mockReturnValue(false);
const mockStatSync = vi.fn<(p: string) => { isDirectory: () => boolean }>().mockReturnValue({ isDirectory: () => true });
const mockReadFile = vi.fn<(p: string, enc: string) => Promise<string>>().mockRejectedValue(new Error("not found"));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(args[0] as string),
statSync: (...args: Parameters<typeof actual.statSync>) => mockStatSync(args[0] as string),
};
});
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
return {
...actual,
readFile: (...args: Parameters<typeof actual.readFile>) =>
mockReadFile(args[0] as string, (args[1] ?? "utf-8") as string),
};
});
// ── Mock project store resolver ──────────────────────────────────
const mockGetOrCreateProjectStore = vi.fn();
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockImplementation(mockGetOrCreateProjectStore);
// ── Helpers ──────────────────────────────────────────────────────
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 VALID_MANIFEST = {
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
description: "A valid plugin",
};
const INSTALLED_PLUGIN: PluginInstallation = {
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
description: "A valid plugin",
path: "/home/user/plugins/my-plugin",
enabled: true,
state: "installed",
settings: {},
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
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("POST /api/plugins mode:install — package root path", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let store: TaskStore;
beforeEach(() => {
vi.clearAllMocks();
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("accepts a package root with valid manifest.json and returns 201", async () => {
const pkgRoot = "/home/user/plugins/my-plugin";
mockExistsSync.mockImplementation((p: string) => p === pkgRoot || p === `${pkgRoot}/manifest.json`);
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue(INSTALLED_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: pkgRoot,
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "my-plugin", name: "My Plugin" });
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: "my-plugin" }),
path: pkgRoot,
}),
);
});
it("accepts a dist folder path with valid manifest.json and returns 201", async () => {
const distPath = "/home/user/plugins/my-plugin/dist";
mockExistsSync.mockImplementation((p: string) => p === distPath || p === `${distPath}/manifest.json`);
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
path: distPath,
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: distPath,
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "my-plugin" });
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: distPath }),
);
});
it("loads plugin after registration when enabled", async () => {
const pkgRoot = "/some/path";
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
enabled: true,
});
await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: pkgRoot,
});
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
});
});
// ══════════════════════════════════════════════════════════════════
describe("POST /api/plugins mode:install — negative paths", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let store: TaskStore;
beforeEach(() => {
vi.clearAllMocks();
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("returns 404 when path does not exist", async () => {
mockExistsSync.mockReturnValue(false);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/nonexistent/dir",
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("does not exist");
});
it("returns 404 when directory exists but manifest.json is missing", async () => {
// Directory exists, but no manifest.json inside it
mockExistsSync.mockImplementation((p: string) => p === "/empty/dir");
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/empty/dir",
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("manifest");
});
it("returns 400 when manifest.json is not valid JSON", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue("not valid json {{{");
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/bad/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid JSON");
});
it("returns 400 when manifest is missing required 'id' field", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue(
JSON.stringify({ name: "No Id", version: "1.0.0" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/missing/id",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid plugin manifest");
expect(res.body.error).toMatch(/id/i);
});
it("returns 400 when manifest is missing required 'name' field", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue(
JSON.stringify({ id: "no-name", version: "1.0.0" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/missing/name",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid plugin manifest");
expect(res.body.error).toMatch(/name/i);
});
it("returns 400 when manifest is missing required 'version' field", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue(
JSON.stringify({ id: "no-ver", name: "No Version" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/missing/version",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid plugin manifest");
expect(res.body.error).toMatch(/version/i);
});
it("returns 400 when path is empty string", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: " ",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("path");
});
it("returns 400 when path is missing entirely", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("path");
});
it("returns 400 when mode is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
path: "/some/path",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("mode");
});
it("returns 400 for unknown mode value", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "magic",
path: "/some/path",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid mode");
});
it("returns 409 when plugin is already registered", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Plugin "my-plugin" is already registered'),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/dup/plugin",
});
expect(res.status).toBe(409);
expect(res.body.error).toContain("already registered");
});
it("returns 400 when plugin loader is not available (install mode)", 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: "/some/path",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("not supported");
});
});
// ══════════════════════════════════════════════════════════════════
describe("POST /api/plugins mode:install — manifest validation edge cases", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let store: TaskStore;
beforeEach(() => {
vi.clearAllMocks();
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("rejects manifest with invalid id format (uppercase)", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue(
JSON.stringify({ id: "BadId", name: "Bad", version: "1.0.0" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/bad/id-format",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid plugin manifest");
});
it("rejects manifest that is an array", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue(JSON.stringify([1, 2, 3]));
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/array/manifest",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid plugin manifest");
});
it("accepts a fully valid manifest with optional fields", async () => {
const fullManifest = {
id: "full-plugin",
name: "Full Plugin",
version: "2.0.0",
description: "Has everything",
author: "Test",
homepage: "https://example.com",
};
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue(JSON.stringify(fullManifest));
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
id: "full-plugin",
name: "Full Plugin",
version: "2.0.0",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "/full/plugin",
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({
id: "full-plugin",
description: "Has everything",
author: "Test",
}),
}),
);
});
});
// ══════════════════════════════════════════════════════════════════
describe("POST /api/plugins mode:install — dist-folder parent resolution", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let store: TaskStore;
beforeEach(() => {
vi.clearAllMocks();
pluginStore = createMockPluginStore({
registerPlugin: vi.fn().mockResolvedValue(INSTALLED_PLUGIN),
});
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("resolves manifest from parent when dist/ folder is selected", async () => {
const distPath = "/home/user/plugins/my-plugin/dist";
const parentPath = "/home/user/plugins/my-plugin";
// dist exists, no manifest in dist, but manifest in parent
mockExistsSync.mockImplementation((p: string) =>
p === distPath || p === `${parentPath}/manifest.json`,
);
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: distPath,
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: parentPath }),
);
});
it("resolves manifest from parent when build/ folder is selected", async () => {
const buildPath = "/home/user/plugins/my-plugin/build";
const parentPath = "/home/user/plugins/my-plugin";
mockExistsSync.mockImplementation((p: string) =>
p === buildPath || p === `${parentPath}/manifest.json`,
);
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: buildPath,
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: parentPath }),
);
});
it("resolves manifest from parent when lib/ folder is selected", async () => {
const libPath = "/home/user/plugins/my-plugin/lib";
const parentPath = "/home/user/plugins/my-plugin";
mockExistsSync.mockImplementation((p: string) =>
p === libPath || p === `${parentPath}/manifest.json`,
);
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: libPath,
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: parentPath }),
);
});
it("does NOT look in parent for non-dist directories like src/", async () => {
const srcPath = "/home/user/plugins/my-plugin/src";
const parentPath = "/home/user/plugins/my-plugin";
mockExistsSync.mockImplementation((p: string) =>
p === srcPath || p === `${parentPath}/manifest.json`,
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: srcPath,
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("manifest");
});
it("prefers manifest in selected dir over parent", async () => {
const distPath = "/home/user/plugins/my-plugin/dist";
const parentPath = "/home/user/plugins/my-plugin";
// Both dist and parent have manifest.json
mockExistsSync.mockImplementation((p: string) =>
p === distPath || p === `${distPath}/manifest.json` || p === `${parentPath}/manifest.json`,
);
const distManifest = { ...VALID_MANIFEST, id: "dist-manifest" };
mockReadFile.mockResolvedValue(JSON.stringify(distManifest));
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: distPath,
});
expect(res.status).toBe(201);
// Should use the dist dir path since it has its own manifest
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: distPath }),
);
});
});

View File

@@ -16,8 +16,8 @@
*/
import { Router, type Request, type Response } from "express";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { existsSync, statSync } from "node:fs";
import { join, isAbsolute, dirname, basename } from "node:path";
import type {
PluginInstallation,
PluginLoader,
@@ -39,6 +39,137 @@ interface PluginRunner {
getPluginRoutes(): Array<{ pluginId: string; route: import("@fusion/core").PluginRouteDefinition }>;
}
// ── Install-Source Resolution Helpers ──────────────────────────────────
// Exported for reuse in routes.ts and for direct testing.
/**
* Validate plugin installation source.
* Must have either `path` (local directory) or `package` (npm package name).
* Enforces absolute path requirement and rejects path traversal.
*/
export function validateInstallSource(body: unknown): { path?: string; package?: string } {
if (!body || typeof body !== "object") {
throw badRequest("Request body is required");
}
const b = body as Record<string, unknown>;
if (b.path !== undefined && typeof b.path === "string") {
const p = b.path;
if (!p.trim()) {
throw badRequest("Path must not be empty");
}
if (!isAbsolute(p)) {
throw badRequest("Plugin path must be absolute");
}
// Reject path traversal sequences
if (p.includes("..")) {
throw badRequest("Plugin path must not contain path traversal (..)");
}
return { path: p };
}
if (b.package !== undefined && typeof b.package === "string") {
return { package: b.package };
}
throw badRequest("Request body must have either 'path' or 'package' field");
}
/**
* Well-known directory names that indicate a build output folder.
* When the user selects one of these, we look for manifest.json
* in the parent directory before giving up.
*/
export const DIST_DIR_NAMES = new Set(["dist", "build", "out", "output", "lib"]);
/**
* Resolve an install path to the directory that contains `manifest.json`.
*
* Resolution order:
* 1. `<path>/manifest.json` — user selected package root
* 2. `<parent>/manifest.json` — user selected a dist/build folder
* (only when `basename(path)` is a well-known build output name)
*
* Returns `{ manifestDir, manifest }` where `manifestDir` is the canonical
* path the plugin-loader should use (the directory containing manifest.json).
*/
export async function resolvePluginManifest(
sourcePath: string,
): Promise<{ manifestDir: string; manifest: import("@fusion/core").PluginManifest }> {
// Validate the path exists and is a directory
if (!existsSync(sourcePath)) {
throw notFound(`Path does not exist: ${sourcePath}`);
}
let stat;
try {
stat = statSync(sourcePath);
} catch {
throw badRequest(`Cannot access path: ${sourcePath}`);
}
if (!stat.isDirectory()) {
throw badRequest(`Path is not a directory: ${sourcePath}`);
}
const { readFile } = await import("node:fs/promises");
// 1. Try manifest.json directly in the provided path
const directManifestPath = join(sourcePath, "manifest.json");
if (existsSync(directManifestPath)) {
const manifest = await readAndValidateManifest(readFile, directManifestPath);
return { manifestDir: sourcePath, manifest };
}
// 2. If the selected dir is a well-known dist folder, check the parent
const dirName = basename(sourcePath).toLowerCase();
if (DIST_DIR_NAMES.has(dirName)) {
const parentDir = dirname(sourcePath);
const parentManifestPath = join(parentDir, "manifest.json");
if (existsSync(parentManifestPath)) {
const manifest = await readAndValidateManifest(readFile, parentManifestPath);
// Return the parent (package root) as the canonical install dir
return { manifestDir: parentDir, manifest };
}
}
// Neither location has a manifest
throw notFound(
`Plugin manifest not found. Looked for manifest.json in: ${sourcePath}` +
(DIST_DIR_NAMES.has(dirName) ? ` and ${dirname(sourcePath)}` : ""),
);
}
/**
* Read and validate a manifest.json file.
*/
async function readAndValidateManifest(
readFile: (path: string, encoding: BufferEncoding) => Promise<string>,
manifestPath: string,
): Promise<import("@fusion/core").PluginManifest> {
let content: string;
try {
content = await readFile(manifestPath, "utf-8");
} catch (err) {
throw badRequest(`Cannot read manifest at ${manifestPath}: ${(err as Error).message}`);
}
let manifest: unknown;
try {
manifest = JSON.parse(content);
} catch {
throw badRequest(`Invalid JSON in manifest at: ${manifestPath}`);
}
const validation = validatePluginManifest(manifest);
if (!validation.valid) {
throw badRequest(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
}
return manifest as import("@fusion/core").PluginManifest;
}
// ── Router Factory ────────────────────────────────────────────────────
/**
* Create the plugin management router.
*
@@ -57,63 +188,6 @@ export function createPluginRouter(
router.use(catchHandler);
// ── Helper Functions ────────────────────────────────────────────
/**
* Validate plugin installation source.
* Must have either `path` (local directory) or `package` (npm package name).
*/
function validateInstallSource(body: unknown): { path?: string; package?: string } {
if (!body || typeof body !== "object") {
throw badRequest("Request body is required");
}
const b = body as Record<string, unknown>;
if (b.path !== undefined && typeof b.path === "string") {
return { path: b.path };
}
if (b.package !== undefined && typeof b.package === "string") {
return { package: b.package };
}
throw badRequest("Request body must have either 'path' or 'package' field");
}
/**
* Load plugin manifest from a path or package.
*/
async function loadPluginManifest(source: { path?: string; package?: string }): Promise<import("@fusion/core").PluginManifest> {
if (source.path) {
// Load from local path
const manifestPath = join(source.path, "manifest.json");
if (!existsSync(manifestPath)) {
throw notFound(`Plugin manifest not found at: ${manifestPath}`);
}
const { readFile } = await import("node:fs/promises");
const content = await readFile(manifestPath, "utf-8");
const manifest = JSON.parse(content);
// Validate manifest
const validation = validatePluginManifest(manifest);
if (!validation.valid) {
throw badRequest(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
}
return manifest as import("@fusion/core").PluginManifest;
}
if (source.package) {
// Load from npm package - this would require dynamic import
// For now, throw an error indicating this is not yet supported
throw badRequest("Installing plugins from npm packages is not yet implemented");
}
throw badRequest("Invalid source");
}
// ── Management Routes ───────────────────────────────────────────
/**
@@ -145,15 +219,25 @@ export function createPluginRouter(
/**
* POST /plugins/install
* Install a plugin from a local path or npm package.
* Supports package root and dist-folder selections via resolvePluginManifest.
*/
router.post("/install", catchHandler(async (req: Request, res: Response) => {
const source = validateInstallSource(req.body);
// Load manifest from source
const manifest = await loadPluginManifest(source);
// Resolve manifest — supports package root and dist-folder selections
let manifest: import("@fusion/core").PluginManifest;
let installPath: string;
// Determine the path to store
const installPath = source.path ?? source.package ?? "";
if (source.path) {
const resolved = await resolvePluginManifest(source.path);
manifest = resolved.manifest;
installPath = resolved.manifestDir;
} else if (source.package) {
// npm packages not yet supported
throw badRequest("Installing plugins from npm packages is not yet implemented");
} else {
throw badRequest("Invalid source");
}
// Register the plugin
try {

View File

@@ -58,6 +58,7 @@ import {
unauthorized,
} from "./api-error.js";
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
import { resolvePluginManifest } from "./plugin-routes.js";
/**
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
@@ -11933,6 +11934,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
} else if (mode === "install") {
// Install mode: requires path, loads manifest from path
// Supports package root and dist-folder selections via resolvePluginManifest
if (typeof body.path !== "string" || !body.path.trim()) {
throw badRequest("'path' is required for install mode and must be a non-empty string");
}
@@ -11942,42 +11944,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
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(", ")}`);
}
// Resolve manifest — supports package root and dist-folder selections
const { manifestDir, manifest } = await resolvePluginManifest(body.path as string);
try {
const plugin = await pluginStore.registerPlugin({
manifest,
path: installPath,
path: manifestDir,
});
// If enabled, try to load the plugin