diff --git a/.changeset/fn-1493-plugin-install-browser.md b/.changeset/fn-1493-plugin-install-browser.md
new file mode 100644
index 000000000..81895c340
--- /dev/null
+++ b/.changeset/fn-1493-plugin-install-browser.md
@@ -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.
diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts
index 6401661ca..0f85e9bd1 100644
--- a/packages/dashboard/app/api.ts
+++ b/packages/dashboard/app/api.ts
@@ -4627,9 +4627,9 @@ export async function installPlugin(
source: { path: string } | { package: string },
projectId?: string,
): Promise {
- return api(withProjectId("/plugins/install", projectId), {
+ return api(withProjectId("/plugins", projectId), {
method: "POST",
- body: JSON.stringify(source),
+ body: JSON.stringify({ mode: "install", ...source }),
});
}
diff --git a/packages/dashboard/app/components/DirectoryPicker.tsx b/packages/dashboard/app/components/DirectoryPicker.tsx
index 986c1d56f..7ada7f699 100644
--- a/packages/dashboard/app/components/DirectoryPicker.tsx
+++ b/packages/dashboard/app/components/DirectoryPicker.tsx
@@ -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) => 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({
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"}
/>
+ 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();
+ }
+ }}
/>
-
diff --git a/packages/dashboard/app/components/__tests__/PluginManager.install-browse.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.install-browse.test.tsx
new file mode 100644
index 000000000..67ced124d
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/PluginManager.install-browse.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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("");
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx
index 938fe241c..3366e2a0b 100644
--- a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx
+++ b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx
@@ -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();
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(() => {
diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css
index deb2a6841..1deb1be4e 100644
--- a/packages/dashboard/app/styles.css
+++ b/packages/dashboard/app/styles.css
@@ -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);
+}
diff --git a/packages/dashboard/src/plugin-routes.test.ts b/packages/dashboard/src/plugin-routes.test.ts
new file mode 100644
index 000000000..544b2c081
--- /dev/null
+++ b/packages/dashboard/src/plugin-routes.test.ts
@@ -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("@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>().mockRejectedValue(new Error("not found"));
+
+vi.mock("node:fs", async () => {
+ const actual = await vi.importActual("node:fs");
+ return {
+ ...actual,
+ existsSync: (...args: Parameters) => mockExistsSync(args[0] as string),
+ statSync: (...args: Parameters) => mockStatSync(args[0] as string),
+ };
+});
+
+vi.mock("node:fs/promises", async () => {
+ const actual = await vi.importActual("node:fs/promises");
+ return {
+ ...actual,
+ readFile: (...args: Parameters) =>
+ 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 {
+ 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 {
+ 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 {
+ 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).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).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).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).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).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 }),
+ );
+ });
+});
diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts
index 5be4661b8..bd34fdb54 100644
--- a/packages/dashboard/src/plugin-routes.ts
+++ b/packages/dashboard/src/plugin-routes.ts
@@ -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;
+
+ 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. `/manifest.json` — user selected package root
+ * 2. `/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,
+ manifestPath: string,
+): Promise {
+ 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;
-
- 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 {
- 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 {
diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts
index 109ec6181..f17a9c8ca 100644
--- a/packages/dashboard/src/routes.ts
+++ b/packages/dashboard/src/routes.ts
@@ -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