From 684762e3e9e3fcc29e654a6f6ef4ca08a9c105d9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Apr 2026 18:10:39 -0700 Subject: [PATCH] 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 --- .changeset/fn-1493-plugin-install-browser.md | 5 + packages/dashboard/app/api.ts | 4 +- .../app/components/DirectoryPicker.tsx | 5 +- .../app/components/PluginManager.tsx | 24 +- .../PluginManager.install-browse.test.tsx | 301 ++++++++ .../__tests__/PluginManager.test.tsx | 38 +- packages/dashboard/app/styles.css | 154 ++++ packages/dashboard/src/plugin-routes.test.ts | 659 ++++++++++++++++++ packages/dashboard/src/plugin-routes.ts | 210 ++++-- packages/dashboard/src/routes.ts | 37 +- 10 files changed, 1318 insertions(+), 119 deletions(-) create mode 100644 .changeset/fn-1493-plugin-install-browser.md create mode 100644 packages/dashboard/app/components/__tests__/PluginManager.install-browse.test.tsx create mode 100644 packages/dashboard/src/plugin-routes.test.ts 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"} /> - 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