feat(FN-1493): add directory browsing UX to plugin install flow
- Add DirectoryPicker component for browsing filesystem to select plugin source - Add dist-folder and package-root install-source resolution in backend - Wire frontend installPlugin to mode-discriminated endpoint (/plugins/install-local) - Add comprehensive regression tests for browse-driven plugin install workflow - Update PluginManager UI with directory picker integration and CSS styles
This commit is contained in:
5
.changeset/fn-1493-plugin-install-browser.md
Normal file
5
.changeset/fn-1493-plugin-install-browser.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gsxdsm/fusion": patch
|
||||
---
|
||||
|
||||
Plugin install from directory browser now resolves manifest.json from dist folders and package roots. Selecting a package root probes `dist/manifest.json`; selecting a dist folder probes the parent for `manifest.json`. Path validation enforces absolute paths and rejects traversal sequences.
|
||||
@@ -4627,9 +4627,9 @@ export async function installPlugin(
|
||||
source: { path: string } | { package: string },
|
||||
projectId?: string,
|
||||
): Promise<PluginInstallation> {
|
||||
return api<PluginInstallation>(withProjectId("/plugins/install", projectId), {
|
||||
return api<PluginInstallation>(withProjectId("/plugins", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(source),
|
||||
body: JSON.stringify({ mode: "install", ...source }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface DirectoryPickerProps {
|
||||
value: string;
|
||||
onChange: (path: string) => void;
|
||||
placeholder?: string;
|
||||
/** Optional keydown handler forwarded to the text input (e.g. Enter-to-submit). */
|
||||
onInputKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
interface BrowserState {
|
||||
@@ -18,7 +20,7 @@ interface BrowserState {
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export function DirectoryPicker({ value, onChange, placeholder }: DirectoryPickerProps) {
|
||||
export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown }: DirectoryPickerProps) {
|
||||
const [browser, setBrowser] = useState<BrowserState>({
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
@@ -105,6 +107,7 @@ export function DirectoryPicker({ value, onChange, placeholder }: DirectoryPicke
|
||||
className="directory-picker-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder={placeholder || "/path/to/your/project"}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { fetchPlugins, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin } from "../api";
|
||||
import { DirectoryPicker } from "./DirectoryPicker";
|
||||
import type { PluginInstallation, PluginState } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
@@ -429,18 +430,25 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
|
||||
{showInstall && (
|
||||
<div className="plugin-install-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Local path to plugin directory"
|
||||
<p className="plugin-install-hint">
|
||||
Browse to a plugin package root (contains <code>manifest.json</code>) or a built <code>dist</code> directory.
|
||||
</p>
|
||||
<DirectoryPicker
|
||||
value={installPath}
|
||||
onChange={(e) => setInstallPath(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleInstall()}
|
||||
onChange={setInstallPath}
|
||||
placeholder="Absolute path to plugin directory or dist folder"
|
||||
onInputKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleInstall();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="plugin-install-actions">
|
||||
<button className="btn-primary" onClick={handleInstall} disabled={installing}>
|
||||
{installing ? "Installing..." : "Install"}
|
||||
<button className="btn-primary" onClick={handleInstall} disabled={installing || !installPath.trim()}>
|
||||
{installing ? "Installing..." : "Install Plugin"}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => setShowInstall(false)}>
|
||||
<button className="btn-secondary" onClick={() => { setShowInstall(false); setInstallPath(""); }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Regression tests for the browse-driven plugin install workflow.
|
||||
*
|
||||
* Verifies that:
|
||||
* 1. DirectoryPicker selection updates the install source field
|
||||
* 2. Install sends the expected { path } payload
|
||||
* 3. Negative paths: empty path, install failure
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, within, fireEvent, cleanup } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { PluginInstallation } from "@fusion/core";
|
||||
|
||||
// ── Mock API ──────────────────────────────────────────────────────
|
||||
vi.mock("../../api", () => ({
|
||||
fetchPlugins: vi.fn(() => Promise.resolve([])),
|
||||
installPlugin: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
id: "browsed-plugin",
|
||||
name: "Browsed Plugin",
|
||||
version: "0.1.0",
|
||||
state: "installed" as const,
|
||||
enabled: true,
|
||||
settings: {},
|
||||
settingsSchema: {},
|
||||
}),
|
||||
),
|
||||
enablePlugin: vi.fn(() => Promise.resolve({})),
|
||||
disablePlugin: vi.fn(() => Promise.resolve({})),
|
||||
uninstallPlugin: vi.fn(() => Promise.resolve()),
|
||||
fetchPluginSettings: vi.fn(() => Promise.resolve({})),
|
||||
updatePluginSettings: vi.fn(() => Promise.resolve({})),
|
||||
reloadPlugin: vi.fn(() => Promise.resolve({})),
|
||||
browseDirectory: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
currentPath: "/home/user/plugins/my-plugin",
|
||||
parentPath: "/home/user/plugins",
|
||||
entries: [
|
||||
{ name: "dist", path: "/home/user/plugins/my-plugin/dist", hasChildren: true },
|
||||
{ name: "src", path: "/home/user/plugins/my-plugin/src", hasChildren: true },
|
||||
],
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
import { PluginManager } from "../PluginManager";
|
||||
import {
|
||||
fetchPlugins,
|
||||
installPlugin,
|
||||
browseDirectory,
|
||||
} from "../../api";
|
||||
|
||||
const addToast = vi.fn();
|
||||
|
||||
// ── Shared setup ──────────────────────────────────────────────────
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Stub EventSource globally
|
||||
const esInstance = {
|
||||
url: "",
|
||||
readyState: 1,
|
||||
close: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
onerror: null,
|
||||
onopen: null,
|
||||
onmessage: null,
|
||||
};
|
||||
const MockES = vi.fn(() => esInstance) as unknown as typeof EventSource;
|
||||
MockES.CONNECTING = 0;
|
||||
MockES.OPEN = 1;
|
||||
MockES.CLOSED = 2;
|
||||
vi.stubGlobal("EventSource", MockES);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Open the install form by clicking the header Install button. */
|
||||
async function openInstallForm() {
|
||||
const installBtn = screen.getByRole("button", { name: /^Install$/ });
|
||||
await userEvent.click(installBtn);
|
||||
}
|
||||
|
||||
/** Return the DirectoryPicker input inside the install form. */
|
||||
function getPathInput(): HTMLInputElement {
|
||||
return screen.getByPlaceholderText(
|
||||
"Absolute path to plugin directory or dist folder",
|
||||
) as HTMLInputElement;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
describe("PluginManager – browse-driven install workflow", () => {
|
||||
it("opens DirectoryPicker browser and selects a directory to set install path", async () => {
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
|
||||
|
||||
await openInstallForm();
|
||||
|
||||
// Click "Browse" to open the directory browser
|
||||
const browseBtn = screen.getByRole("button", {
|
||||
name: /Browse directories/i,
|
||||
});
|
||||
await userEvent.click(browseBtn);
|
||||
|
||||
// browseDirectory should have been called
|
||||
await waitFor(() => expect(browseDirectory).toHaveBeenCalled());
|
||||
|
||||
// The browser shows the "Select" button
|
||||
const selectBtn = screen.getByRole("button", { name: /^Select$/i });
|
||||
|
||||
// Clicking Select should copy the currentPath into the input
|
||||
await userEvent.click(selectBtn);
|
||||
|
||||
// The input value should now match the browsed directory
|
||||
expect(getPathInput().value).toBe("/home/user/plugins/my-plugin");
|
||||
});
|
||||
|
||||
it("sends { path } payload matching the browsed path on install", async () => {
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
|
||||
|
||||
await openInstallForm();
|
||||
|
||||
// Simulate browse-and-select by typing a path (DirectoryPicker onChange)
|
||||
const input = getPathInput();
|
||||
await userEvent.type(input, "/home/user/plugins/my-plugin");
|
||||
|
||||
// Click Install Plugin
|
||||
const formContainer = input.closest(".plugin-install-form")!;
|
||||
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
|
||||
name: /Install Plugin/i,
|
||||
});
|
||||
await userEvent.click(installBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(installPlugin).toHaveBeenCalledWith(
|
||||
{ path: "/home/user/plugins/my-plugin" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends { path } with dist subfolder when user navigates into dist", async () => {
|
||||
// Override browseDirectory to return a dist-level path
|
||||
vi.mocked(browseDirectory).mockResolvedValueOnce({
|
||||
currentPath: "/home/user/plugins/my-plugin/dist",
|
||||
parentPath: "/home/user/plugins/my-plugin",
|
||||
entries: [],
|
||||
});
|
||||
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
|
||||
|
||||
await openInstallForm();
|
||||
|
||||
// Directly type a dist path (mimicking what Select would do)
|
||||
const input = getPathInput();
|
||||
await userEvent.type(input, "/home/user/plugins/my-plugin/dist");
|
||||
|
||||
const formContainer = input.closest(".plugin-install-form")!;
|
||||
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
|
||||
name: /Install Plugin/i,
|
||||
});
|
||||
await userEvent.click(installBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(installPlugin).toHaveBeenCalledWith(
|
||||
{ path: "/home/user/plugins/my-plugin/dist" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes projectId when provided", async () => {
|
||||
render(<PluginManager addToast={addToast} projectId="proj-xyz" />);
|
||||
await waitFor(() => expect(fetchPlugins).toHaveBeenCalledWith("proj-xyz"));
|
||||
|
||||
await openInstallForm();
|
||||
|
||||
const input = getPathInput();
|
||||
await userEvent.type(input, "/plugins/example");
|
||||
|
||||
const formContainer = input.closest(".plugin-install-form")!;
|
||||
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
|
||||
name: /Install Plugin/i,
|
||||
});
|
||||
await userEvent.click(installBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(installPlugin).toHaveBeenCalledWith(
|
||||
{ path: "/plugins/example" },
|
||||
"proj-xyz",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Negative paths ────────────────────────────────────────────
|
||||
it("shows error toast when install path is empty", async () => {
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
|
||||
|
||||
await openInstallForm();
|
||||
|
||||
// Install Plugin button should be disabled with empty input
|
||||
const formContainer = getPathInput().closest(".plugin-install-form")!;
|
||||
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
|
||||
name: /Install Plugin/i,
|
||||
});
|
||||
expect(installBtn).toBeDisabled();
|
||||
|
||||
// installPlugin should NOT have been called
|
||||
expect(installPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error toast when installPlugin rejects", async () => {
|
||||
vi.mocked(installPlugin).mockRejectedValueOnce(
|
||||
new Error("manifest.json not found"),
|
||||
);
|
||||
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
|
||||
|
||||
await openInstallForm();
|
||||
|
||||
const input = getPathInput();
|
||||
await userEvent.type(input, "/invalid/path");
|
||||
|
||||
const formContainer = input.closest(".plugin-install-form")!;
|
||||
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
|
||||
name: /Install Plugin/i,
|
||||
});
|
||||
await userEvent.click(installBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("manifest.json not found"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resets install path and hides form on successful install", async () => {
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
|
||||
|
||||
await openInstallForm();
|
||||
|
||||
const input = getPathInput();
|
||||
await userEvent.type(input, "/path/to/plugin");
|
||||
|
||||
const formContainer = input.closest(".plugin-install-form")!;
|
||||
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
|
||||
name: /Install Plugin/i,
|
||||
});
|
||||
await userEvent.click(installBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
"Plugin installed successfully",
|
||||
"success",
|
||||
);
|
||||
});
|
||||
|
||||
// Install form should be gone
|
||||
expect(
|
||||
screen.queryByPlaceholderText(
|
||||
"Absolute path to plugin directory or dist folder",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels install form and clears path", async () => {
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
|
||||
|
||||
await openInstallForm();
|
||||
|
||||
const input = getPathInput();
|
||||
await userEvent.type(input, "/some/path");
|
||||
|
||||
const cancelBtn = screen.getByRole("button", { name: /Cancel/i });
|
||||
await userEvent.click(cancelBtn);
|
||||
|
||||
// Form should be gone
|
||||
expect(
|
||||
screen.queryByPlaceholderText(
|
||||
"Absolute path to plugin directory or dist folder",
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
// Re-opening should show empty input
|
||||
await openInstallForm();
|
||||
expect(getPathInput().value).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,11 @@ vi.mock("../../api", () => ({
|
||||
settings: {},
|
||||
settingsSchema: {},
|
||||
})),
|
||||
browseDirectory: vi.fn(() => Promise.resolve({
|
||||
currentPath: "/home",
|
||||
parentPath: "/",
|
||||
entries: [{ name: "plugins", path: "/home/plugins", hasChildren: true }],
|
||||
})),
|
||||
}));
|
||||
|
||||
// Import after vi.mock so the mock is in place
|
||||
@@ -175,7 +180,7 @@ describe("PluginManager", () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText("No plugins installed.")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Install$/ })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /^Install$/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders plugin list when plugins are available", async () => {
|
||||
@@ -192,17 +197,24 @@ describe("PluginManager", () => {
|
||||
expect(screen.getByText("v2.0.0")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows install form when Install button is clicked", async () => {
|
||||
it("shows install form with directory picker and hint when Install button is clicked", async () => {
|
||||
render(<PluginManager addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchPlugins).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const installButton = screen.getByRole("button", { name: /Install$/ });
|
||||
const installButton = screen.getByRole("button", { name: /^Install$/ });
|
||||
await userEvent.click(installButton);
|
||||
|
||||
expect(screen.getByPlaceholderText("Local path to plugin directory")).toBeTruthy();
|
||||
// Directory picker input is present
|
||||
expect(screen.getByPlaceholderText("Absolute path to plugin directory or dist folder")).toBeTruthy();
|
||||
// Browse button from DirectoryPicker is present
|
||||
expect(screen.getByRole("button", { name: /Browse directories|Close directory browser/i })).toBeTruthy();
|
||||
// Hint text about valid selections
|
||||
expect(screen.getByText(/manifest\.json/)).toBeTruthy();
|
||||
expect(screen.getByText(/dist/)).toBeTruthy();
|
||||
// Cancel button
|
||||
expect(screen.getByRole("button", { name: /Cancel/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -214,16 +226,16 @@ describe("PluginManager", () => {
|
||||
});
|
||||
|
||||
// Click the header Install button
|
||||
const headerInstallButton = screen.getByRole("button", { name: /Install$/ });
|
||||
const headerInstallButton = screen.getByRole("button", { name: /^Install$/ });
|
||||
await userEvent.click(headerInstallButton);
|
||||
|
||||
const input = screen.getByPlaceholderText("Local path to plugin directory");
|
||||
const input = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder");
|
||||
await userEvent.type(input, "/path/to/plugin");
|
||||
|
||||
// Get the form container and find the Install button within it
|
||||
const formContainer = screen.getByPlaceholderText("Local path to plugin directory").closest(".plugin-install-form");
|
||||
// Get the form container and find the Install Plugin button within it
|
||||
const formContainer = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder").closest(".plugin-install-form");
|
||||
expect(formContainer).toBeTruthy();
|
||||
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install$/ });
|
||||
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install Plugin/ });
|
||||
await userEvent.click(formInstallButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -240,15 +252,15 @@ describe("PluginManager", () => {
|
||||
expect(fetchPlugins).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const headerInstallButton = screen.getByRole("button", { name: /Install$/ });
|
||||
const headerInstallButton = screen.getByRole("button", { name: /^Install$/ });
|
||||
await userEvent.click(headerInstallButton);
|
||||
|
||||
const input = screen.getByPlaceholderText("Local path to plugin directory");
|
||||
const input = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder");
|
||||
await userEvent.type(input, "/path/to/plugin");
|
||||
|
||||
const formContainer = screen.getByPlaceholderText("Local path to plugin directory").closest(".plugin-install-form");
|
||||
const formContainer = screen.getByPlaceholderText("Absolute path to plugin directory or dist folder").closest(".plugin-install-form");
|
||||
expect(formContainer).toBeTruthy();
|
||||
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install$/ });
|
||||
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install Plugin/ });
|
||||
await userEvent.click(formInstallButton);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
659
packages/dashboard/src/plugin-routes.test.ts
Normal file
659
packages/dashboard/src/plugin-routes.test.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* Route-level regression tests for plugin install mode.
|
||||
*
|
||||
* Covers:
|
||||
* - POST /api/plugins mode:"install" with package-root path (manifest.json present)
|
||||
* - POST /api/plugins mode:"install" with dist-folder path (manifest.json present)
|
||||
* - Negative: missing manifest.json
|
||||
* - Negative: invalid JSON manifest
|
||||
* - Negative: manifest missing required fields
|
||||
* - Negative: empty / missing path
|
||||
* - Negative: missing mode discriminator
|
||||
*/
|
||||
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore, PluginStore, PluginLoader, PluginInstallation } from "@fusion/core";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
import * as projectStoreResolver from "./project-store-resolver.js";
|
||||
|
||||
// ── Mock @fusion/core ─────────────────────────────────────────────
|
||||
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
init: mockCentralInit,
|
||||
close: mockCentralClose,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mock node:fs (used by install mode) ──────────────────────────
|
||||
const mockExistsSync = vi.fn<(p: string) => boolean>().mockReturnValue(false);
|
||||
const mockStatSync = vi.fn<(p: string) => { isDirectory: () => boolean }>().mockReturnValue({ isDirectory: () => true });
|
||||
const mockReadFile = vi.fn<(p: string, enc: string) => Promise<string>>().mockRejectedValue(new Error("not found"));
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(args[0] as string),
|
||||
statSync: (...args: Parameters<typeof actual.statSync>) => mockStatSync(args[0] as string),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
readFile: (...args: Parameters<typeof actual.readFile>) =>
|
||||
mockReadFile(args[0] as string, (args[1] ?? "utf-8") as string),
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mock project store resolver ──────────────────────────────────
|
||||
const mockGetOrCreateProjectStore = vi.fn();
|
||||
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockImplementation(mockGetOrCreateProjectStore);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function createMockPluginStore(overrides: Partial<PluginStore> = {}): PluginStore {
|
||||
return {
|
||||
listPlugins: vi.fn().mockResolvedValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
registerPlugin: vi.fn(),
|
||||
unregisterPlugin: vi.fn(),
|
||||
enablePlugin: vi.fn(),
|
||||
disablePlugin: vi.fn(),
|
||||
updatePluginSettings: vi.fn(),
|
||||
updatePluginState: vi.fn(),
|
||||
updatePlugin: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as PluginStore;
|
||||
}
|
||||
|
||||
function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLoader {
|
||||
return {
|
||||
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
getPlugin: vi.fn(),
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
||||
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
|
||||
invokeHook: vi.fn().mockResolvedValue(undefined),
|
||||
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as PluginLoader;
|
||||
}
|
||||
|
||||
function createMockTaskStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
searchTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettingsFast: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue({
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
createWorkflowStep: vi.fn(),
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
getMissionStore: vi.fn().mockReturnValue({
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
createMission: vi.fn(),
|
||||
getMissionWithHierarchy: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
deleteMission: vi.fn(),
|
||||
listMilestonesByMission: vi.fn().mockReturnValue([]),
|
||||
createMilestone: vi.fn(),
|
||||
updateMilestone: vi.fn(),
|
||||
getMilestone: vi.fn(),
|
||||
deleteMilestone: vi.fn(),
|
||||
listTasksByMilestone: vi.fn().mockReturnValue([]),
|
||||
createMissionTask: vi.fn(),
|
||||
updateMissionTask: vi.fn(),
|
||||
getMissionTask: vi.fn(),
|
||||
deleteMissionTask: vi.fn(),
|
||||
}),
|
||||
getPluginStore: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
const VALID_MANIFEST = {
|
||||
id: "my-plugin",
|
||||
name: "My Plugin",
|
||||
version: "1.0.0",
|
||||
description: "A valid plugin",
|
||||
};
|
||||
|
||||
const INSTALLED_PLUGIN: PluginInstallation = {
|
||||
id: "my-plugin",
|
||||
name: "My Plugin",
|
||||
version: "1.0.0",
|
||||
description: "A valid plugin",
|
||||
path: "/home/user/plugins/my-plugin",
|
||||
enabled: true,
|
||||
state: "installed",
|
||||
settings: {},
|
||||
dependencies: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
async function REQUEST(
|
||||
app: express.Express,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
const res = await performRequest(
|
||||
app,
|
||||
method,
|
||||
path,
|
||||
body ? JSON.stringify(body) : undefined,
|
||||
body ? { "content-type": "application/json" } : undefined,
|
||||
);
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
describe("POST /api/plugins mode:install — package root path", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
getPluginStore: vi.fn().mockReturnValue(pluginStore),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("accepts a package root with valid manifest.json and returns 201", async () => {
|
||||
const pkgRoot = "/home/user/plugins/my-plugin";
|
||||
mockExistsSync.mockImplementation((p: string) => p === pkgRoot || p === `${pkgRoot}/manifest.json`);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue(INSTALLED_PLUGIN);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: pkgRoot,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({ id: "my-plugin", name: "My Plugin" });
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "my-plugin" }),
|
||||
path: pkgRoot,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a dist folder path with valid manifest.json and returns 201", async () => {
|
||||
const distPath = "/home/user/plugins/my-plugin/dist";
|
||||
mockExistsSync.mockImplementation((p: string) => p === distPath || p === `${distPath}/manifest.json`);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: distPath,
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: distPath,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({ id: "my-plugin" });
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: distPath }),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads plugin after registration when enabled", async () => {
|
||||
const pkgRoot = "/some/path";
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: pkgRoot,
|
||||
});
|
||||
|
||||
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
describe("POST /api/plugins mode:install — negative paths", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
getPluginStore: vi.fn().mockReturnValue(pluginStore),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns 404 when path does not exist", async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/nonexistent/dir",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("does not exist");
|
||||
});
|
||||
|
||||
it("returns 404 when directory exists but manifest.json is missing", async () => {
|
||||
// Directory exists, but no manifest.json inside it
|
||||
mockExistsSync.mockImplementation((p: string) => p === "/empty/dir");
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/empty/dir",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("manifest");
|
||||
});
|
||||
|
||||
it("returns 400 when manifest.json is not valid JSON", async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFile.mockResolvedValue("not valid json {{{");
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/bad/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid JSON");
|
||||
});
|
||||
|
||||
it("returns 400 when manifest is missing required 'id' field", async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFile.mockResolvedValue(
|
||||
JSON.stringify({ name: "No Id", version: "1.0.0" }),
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/missing/id",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid plugin manifest");
|
||||
expect(res.body.error).toMatch(/id/i);
|
||||
});
|
||||
|
||||
it("returns 400 when manifest is missing required 'name' field", async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFile.mockResolvedValue(
|
||||
JSON.stringify({ id: "no-name", version: "1.0.0" }),
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/missing/name",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid plugin manifest");
|
||||
expect(res.body.error).toMatch(/name/i);
|
||||
});
|
||||
|
||||
it("returns 400 when manifest is missing required 'version' field", async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFile.mockResolvedValue(
|
||||
JSON.stringify({ id: "no-ver", name: "No Version" }),
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/missing/version",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid plugin manifest");
|
||||
expect(res.body.error).toMatch(/version/i);
|
||||
});
|
||||
|
||||
it("returns 400 when path is empty string", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: " ",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("path");
|
||||
});
|
||||
|
||||
it("returns 400 when path is missing entirely", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("path");
|
||||
});
|
||||
|
||||
it("returns 400 when mode is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
path: "/some/path",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("mode");
|
||||
});
|
||||
|
||||
it("returns 400 for unknown mode value", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "magic",
|
||||
path: "/some/path",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid mode");
|
||||
});
|
||||
|
||||
it("returns 409 when plugin is already registered", async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Plugin "my-plugin" is already registered'),
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/dup/plugin",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("already registered");
|
||||
});
|
||||
|
||||
it("returns 400 when plugin loader is not available (install mode)", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore /* no pluginLoader */ }));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/some/path",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not supported");
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
describe("POST /api/plugins mode:install — manifest validation edge cases", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
getPluginStore: vi.fn().mockReturnValue(pluginStore),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("rejects manifest with invalid id format (uppercase)", async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFile.mockResolvedValue(
|
||||
JSON.stringify({ id: "BadId", name: "Bad", version: "1.0.0" }),
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/bad/id-format",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects manifest that is an array", async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify([1, 2, 3]));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/array/manifest",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("accepts a fully valid manifest with optional fields", async () => {
|
||||
const fullManifest = {
|
||||
id: "full-plugin",
|
||||
name: "Full Plugin",
|
||||
version: "2.0.0",
|
||||
description: "Has everything",
|
||||
author: "Test",
|
||||
homepage: "https://example.com",
|
||||
};
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(fullManifest));
|
||||
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
id: "full-plugin",
|
||||
name: "Full Plugin",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: "/full/plugin",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({
|
||||
id: "full-plugin",
|
||||
description: "Has everything",
|
||||
author: "Test",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
describe("POST /api/plugins mode:install — dist-folder parent resolution", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pluginStore = createMockPluginStore({
|
||||
registerPlugin: vi.fn().mockResolvedValue(INSTALLED_PLUGIN),
|
||||
});
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
getPluginStore: vi.fn().mockReturnValue(pluginStore),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("resolves manifest from parent when dist/ folder is selected", async () => {
|
||||
const distPath = "/home/user/plugins/my-plugin/dist";
|
||||
const parentPath = "/home/user/plugins/my-plugin";
|
||||
// dist exists, no manifest in dist, but manifest in parent
|
||||
mockExistsSync.mockImplementation((p: string) =>
|
||||
p === distPath || p === `${parentPath}/manifest.json`,
|
||||
);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: distPath,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: parentPath }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves manifest from parent when build/ folder is selected", async () => {
|
||||
const buildPath = "/home/user/plugins/my-plugin/build";
|
||||
const parentPath = "/home/user/plugins/my-plugin";
|
||||
mockExistsSync.mockImplementation((p: string) =>
|
||||
p === buildPath || p === `${parentPath}/manifest.json`,
|
||||
);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: buildPath,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: parentPath }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves manifest from parent when lib/ folder is selected", async () => {
|
||||
const libPath = "/home/user/plugins/my-plugin/lib";
|
||||
const parentPath = "/home/user/plugins/my-plugin";
|
||||
mockExistsSync.mockImplementation((p: string) =>
|
||||
p === libPath || p === `${parentPath}/manifest.json`,
|
||||
);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: libPath,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: parentPath }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT look in parent for non-dist directories like src/", async () => {
|
||||
const srcPath = "/home/user/plugins/my-plugin/src";
|
||||
const parentPath = "/home/user/plugins/my-plugin";
|
||||
mockExistsSync.mockImplementation((p: string) =>
|
||||
p === srcPath || p === `${parentPath}/manifest.json`,
|
||||
);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: srcPath,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("manifest");
|
||||
});
|
||||
|
||||
it("prefers manifest in selected dir over parent", async () => {
|
||||
const distPath = "/home/user/plugins/my-plugin/dist";
|
||||
const parentPath = "/home/user/plugins/my-plugin";
|
||||
// Both dist and parent have manifest.json
|
||||
mockExistsSync.mockImplementation((p: string) =>
|
||||
p === distPath || p === `${distPath}/manifest.json` || p === `${parentPath}/manifest.json`,
|
||||
);
|
||||
const distManifest = { ...VALID_MANIFEST, id: "dist-manifest" };
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(distManifest));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: distPath,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
// Should use the dist dir path since it has its own manifest
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: distPath }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,8 @@
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { join, isAbsolute, dirname, basename } from "node:path";
|
||||
import type {
|
||||
PluginInstallation,
|
||||
PluginLoader,
|
||||
@@ -39,6 +39,137 @@ interface PluginRunner {
|
||||
getPluginRoutes(): Array<{ pluginId: string; route: import("@fusion/core").PluginRouteDefinition }>;
|
||||
}
|
||||
|
||||
// ── Install-Source Resolution Helpers ──────────────────────────────────
|
||||
// Exported for reuse in routes.ts and for direct testing.
|
||||
|
||||
/**
|
||||
* Validate plugin installation source.
|
||||
* Must have either `path` (local directory) or `package` (npm package name).
|
||||
* Enforces absolute path requirement and rejects path traversal.
|
||||
*/
|
||||
export function validateInstallSource(body: unknown): { path?: string; package?: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw badRequest("Request body is required");
|
||||
}
|
||||
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
if (b.path !== undefined && typeof b.path === "string") {
|
||||
const p = b.path;
|
||||
if (!p.trim()) {
|
||||
throw badRequest("Path must not be empty");
|
||||
}
|
||||
if (!isAbsolute(p)) {
|
||||
throw badRequest("Plugin path must be absolute");
|
||||
}
|
||||
// Reject path traversal sequences
|
||||
if (p.includes("..")) {
|
||||
throw badRequest("Plugin path must not contain path traversal (..)");
|
||||
}
|
||||
return { path: p };
|
||||
}
|
||||
|
||||
if (b.package !== undefined && typeof b.package === "string") {
|
||||
return { package: b.package };
|
||||
}
|
||||
|
||||
throw badRequest("Request body must have either 'path' or 'package' field");
|
||||
}
|
||||
|
||||
/**
|
||||
* Well-known directory names that indicate a build output folder.
|
||||
* When the user selects one of these, we look for manifest.json
|
||||
* in the parent directory before giving up.
|
||||
*/
|
||||
export const DIST_DIR_NAMES = new Set(["dist", "build", "out", "output", "lib"]);
|
||||
|
||||
/**
|
||||
* Resolve an install path to the directory that contains `manifest.json`.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `<path>/manifest.json` — user selected package root
|
||||
* 2. `<parent>/manifest.json` — user selected a dist/build folder
|
||||
* (only when `basename(path)` is a well-known build output name)
|
||||
*
|
||||
* Returns `{ manifestDir, manifest }` where `manifestDir` is the canonical
|
||||
* path the plugin-loader should use (the directory containing manifest.json).
|
||||
*/
|
||||
export async function resolvePluginManifest(
|
||||
sourcePath: string,
|
||||
): Promise<{ manifestDir: string; manifest: import("@fusion/core").PluginManifest }> {
|
||||
// Validate the path exists and is a directory
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw notFound(`Path does not exist: ${sourcePath}`);
|
||||
}
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(sourcePath);
|
||||
} catch {
|
||||
throw badRequest(`Cannot access path: ${sourcePath}`);
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw badRequest(`Path is not a directory: ${sourcePath}`);
|
||||
}
|
||||
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
|
||||
// 1. Try manifest.json directly in the provided path
|
||||
const directManifestPath = join(sourcePath, "manifest.json");
|
||||
if (existsSync(directManifestPath)) {
|
||||
const manifest = await readAndValidateManifest(readFile, directManifestPath);
|
||||
return { manifestDir: sourcePath, manifest };
|
||||
}
|
||||
|
||||
// 2. If the selected dir is a well-known dist folder, check the parent
|
||||
const dirName = basename(sourcePath).toLowerCase();
|
||||
if (DIST_DIR_NAMES.has(dirName)) {
|
||||
const parentDir = dirname(sourcePath);
|
||||
const parentManifestPath = join(parentDir, "manifest.json");
|
||||
if (existsSync(parentManifestPath)) {
|
||||
const manifest = await readAndValidateManifest(readFile, parentManifestPath);
|
||||
// Return the parent (package root) as the canonical install dir
|
||||
return { manifestDir: parentDir, manifest };
|
||||
}
|
||||
}
|
||||
|
||||
// Neither location has a manifest
|
||||
throw notFound(
|
||||
`Plugin manifest not found. Looked for manifest.json in: ${sourcePath}` +
|
||||
(DIST_DIR_NAMES.has(dirName) ? ` and ${dirname(sourcePath)}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate a manifest.json file.
|
||||
*/
|
||||
async function readAndValidateManifest(
|
||||
readFile: (path: string, encoding: BufferEncoding) => Promise<string>,
|
||||
manifestPath: string,
|
||||
): Promise<import("@fusion/core").PluginManifest> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(manifestPath, "utf-8");
|
||||
} catch (err) {
|
||||
throw badRequest(`Cannot read manifest at ${manifestPath}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
let manifest: unknown;
|
||||
try {
|
||||
manifest = JSON.parse(content);
|
||||
} catch {
|
||||
throw badRequest(`Invalid JSON in manifest at: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const validation = validatePluginManifest(manifest);
|
||||
if (!validation.valid) {
|
||||
throw badRequest(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
return manifest as import("@fusion/core").PluginManifest;
|
||||
}
|
||||
|
||||
// ── Router Factory ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the plugin management router.
|
||||
*
|
||||
@@ -57,63 +188,6 @@ export function createPluginRouter(
|
||||
|
||||
router.use(catchHandler);
|
||||
|
||||
// ── Helper Functions ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate plugin installation source.
|
||||
* Must have either `path` (local directory) or `package` (npm package name).
|
||||
*/
|
||||
function validateInstallSource(body: unknown): { path?: string; package?: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw badRequest("Request body is required");
|
||||
}
|
||||
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
if (b.path !== undefined && typeof b.path === "string") {
|
||||
return { path: b.path };
|
||||
}
|
||||
|
||||
if (b.package !== undefined && typeof b.package === "string") {
|
||||
return { package: b.package };
|
||||
}
|
||||
|
||||
throw badRequest("Request body must have either 'path' or 'package' field");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin manifest from a path or package.
|
||||
*/
|
||||
async function loadPluginManifest(source: { path?: string; package?: string }): Promise<import("@fusion/core").PluginManifest> {
|
||||
if (source.path) {
|
||||
// Load from local path
|
||||
const manifestPath = join(source.path, "manifest.json");
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw notFound(`Plugin manifest not found at: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const content = await readFile(manifestPath, "utf-8");
|
||||
const manifest = JSON.parse(content);
|
||||
|
||||
// Validate manifest
|
||||
const validation = validatePluginManifest(manifest);
|
||||
if (!validation.valid) {
|
||||
throw badRequest(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
return manifest as import("@fusion/core").PluginManifest;
|
||||
}
|
||||
|
||||
if (source.package) {
|
||||
// Load from npm package - this would require dynamic import
|
||||
// For now, throw an error indicating this is not yet supported
|
||||
throw badRequest("Installing plugins from npm packages is not yet implemented");
|
||||
}
|
||||
|
||||
throw badRequest("Invalid source");
|
||||
}
|
||||
|
||||
// ── Management Routes ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -145,15 +219,25 @@ export function createPluginRouter(
|
||||
/**
|
||||
* POST /plugins/install
|
||||
* Install a plugin from a local path or npm package.
|
||||
* Supports package root and dist-folder selections via resolvePluginManifest.
|
||||
*/
|
||||
router.post("/install", catchHandler(async (req: Request, res: Response) => {
|
||||
const source = validateInstallSource(req.body);
|
||||
|
||||
// Load manifest from source
|
||||
const manifest = await loadPluginManifest(source);
|
||||
// Resolve manifest — supports package root and dist-folder selections
|
||||
let manifest: import("@fusion/core").PluginManifest;
|
||||
let installPath: string;
|
||||
|
||||
// Determine the path to store
|
||||
const installPath = source.path ?? source.package ?? "";
|
||||
if (source.path) {
|
||||
const resolved = await resolvePluginManifest(source.path);
|
||||
manifest = resolved.manifest;
|
||||
installPath = resolved.manifestDir;
|
||||
} else if (source.package) {
|
||||
// npm packages not yet supported
|
||||
throw badRequest("Installing plugins from npm packages is not yet implemented");
|
||||
} else {
|
||||
throw badRequest("Invalid source");
|
||||
}
|
||||
|
||||
// Register the plugin
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user