Merge pull request #1428 from Runfusion/gsxdsm/compound-plugin
Fix Settings plugin installs registering unloadable directory paths
This commit is contained in:
@@ -7,3 +7,4 @@ Fix the workflow graph editor opening invisibly and bundle the Compound Engineer
|
||||
- The "Graph editor" button now actually shows the editor: its overlay was rendered without the `open` class, leaving it `display: none`, so opening it looked like the workflow steps view was just dismissed.
|
||||
- `fusion-plugin-compound-engineering` and `fusion-plugin-roadmap` are now listed in the dashboard's built-in plugins, so they appear under Settings → Built-in Plugins (they were implemented and registered but missing from the list).
|
||||
- Installing Compound Engineering (and CLI Printing Press) from Settings → Built-in Plugins no longer fails with "Plugin manifest not found": both ids are now in the dashboard's bundled-plugin fallback set, and the Compound Engineering plugin is staged into `dist/plugins/` so packaged installs can resolve it.
|
||||
- Plugins installed from Settings now load instead of erroring with "Plugin entry must be a file, got directory": the dashboard install routes register the plugin's loadable entry file (`bundled.js`/`dist/index.js`/`src/index.ts`) rather than the package directory, and enabling a plugin heals legacy directory-path registrations in place.
|
||||
|
||||
@@ -141,6 +141,10 @@ A plugin that ships inside the Fusion distribution itself rather than being inst
|
||||
*Avoid:* built-in plugin (as a distinct concept; the Settings label uses "Built-in" for the same thing)
|
||||
|
||||
A Bundled Plugin must be registered in several independently maintained surfaces — the Settings catalog, the dashboard server's bundled-id fallback set, the CLI's startup auto-install list, and the build step that stages a loadable copy into the distribution. The surfaces do not cross-check each other: a plugin registered in some but not all appears installable yet fails to install or load, so adding one means mirroring an existing bundled plugin across every surface.
|
||||
|
||||
### Plugin Entry
|
||||
The single loadable file persisted as a plugin's path and dynamically imported by the loader. The contract is strict: a package directory is never a valid entry (ESM cannot import directories), so every install surface must resolve a concrete file before persisting, preferring the shipped bundle, then a prebuilt output, then raw workspace source. Legacy registrations that stored a directory are healed in place — re-pointed at a resolved entry — the next time the plugin is enabled or auto-installed.
|
||||
|
||||
## Workflow columns & traits
|
||||
|
||||
*Behind the `experimentalFeatures.workflowColumns` flag. With the flag off, the legacy fixed pipeline (the closed column enum + `VALID_TRANSITIONS`) is authoritative and unchanged.*
|
||||
|
||||
@@ -12,7 +12,8 @@ symptoms:
|
||||
root_cause: incomplete_setup
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift]
|
||||
last_updated: 2026-06-05
|
||||
tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift, entry-file, fs-mock]
|
||||
---
|
||||
|
||||
# Bundled plugins must be registered in 4 independent places — they drift
|
||||
@@ -63,6 +64,14 @@ await bundlePluginEntry({
|
||||
});
|
||||
```
|
||||
|
||||
## Follow-up failure: directory registered as plugin path
|
||||
|
||||
Fixing the fallback surfaced a second, independent bug (fixed in PR #1428): both dashboard install routes registered the **manifest directory** as the plugin path, but since FN-4128 the loader requires a loadable entry **file** (Node ESM cannot import directories) — enable then failed with `Plugin entry must be a file, got directory: <dir>`. Only the CLI startup path had been migrated to `resolvePluginEntryPath` (`bundled.js` → `dist/index.js` → `src/index.ts`), which is why CLI-auto-installed plugins worked and Settings-installed ones never did. Fix: both install routes now resolve and register the entry file (helper added to `@fusion/core`; 400 with "no loadable entry file" when none exists), and **both** enable routes heal legacy directory-path rows in place before `loadPlugin` — mirroring the CLI's startup heal — so pre-fix broken registrations self-repair on first enable without a migration.
|
||||
|
||||
### Trap: vitest fs mocks don't reach externalized workspace deps
|
||||
|
||||
Moving `resolvePluginEntryPath` to `@fusion/core` and re-exporting from the CLI broke the CLI's tests: `vi.mock("node:fs")` in the CLI package does **not** intercept fs calls made inside the externalized `@fusion/core` import (vitest only inlines/mocks modules in the test package's transform graph — the dashboard package inlines core, the CLI doesn't). Resolution: the CLI keeps an intentionally duplicated local copy (its fs mocks work against it), both copies carry keep-in-sync comments, and a **real-fs drift-guard test** (`packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts`) imports both copies and asserts identical resolution across real temp-dir layouts — each candidate alone, precedence pairs, all three, and the no-entry → `null` case. Real directories are the only seam that exercises both implementations equally; a candidate-list change applied to one copy but not the other now fails CI.
|
||||
|
||||
## Why This Works
|
||||
|
||||
The Settings card sends a relative `./plugins/<id>` path. The server resolves it against `process.cwd()` — normally the user's project dir, not the Fusion repo — so it 404s and falls back to `extractBundledPluginId()`, which only recognizes ids in routes.ts's `BUNDLED_PLUGIN_IDS`. Adding the id makes the fallback resolve the staged bundled copy; the tsup staging block guarantees that copy exists in packaged installs.
|
||||
@@ -71,9 +80,13 @@ The Settings card sends a relative `./plugins/<id>` path. The server resolves it
|
||||
|
||||
- **When adding a bundled plugin, grep for an existing one** (e.g. `rg -l "fusion-plugin-roadmap" packages/` ) and mirror every hit — that surfaces all four lists plus view registration.
|
||||
- Route tests must force the fallback: mock fs so the cwd-relative path **misses** and only `dist/plugins/<id>` exists (see "installs bundled compound engineering plugin when relative path misses cwd" in `packages/dashboard/src/__tests__/plugin-routes.test.ts`). A mock that matches any path containing the plugin id tests nothing.
|
||||
- **Pin assertions to the exact contract, not substring containment.** `stringContaining(pluginId)` passed for both the correct entry-file path and the buggy directory path — when a mock or matcher can satisfy both the correct and the buggy value, the test proves nothing. Route tests now assert the registered path ends in an entry-file suffix, cover the `dist/index.js` and `src/index.ts` fallbacks, and the 400 no-entry branch.
|
||||
- When duplicating a helper is forced by test infrastructure (fs mocks vs externalized deps), add a real-fs drift-guard test that runs every copy against the same on-disk fixtures and asserts identical output.
|
||||
- Consider a future consistency test asserting every `BUILTIN_PLUGINS` UI entry with a `path` is present in both server-side `BUNDLED_PLUGIN_IDS` sets.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- PR #1423 — the fix
|
||||
- PR #1423 — the registration-drift fix
|
||||
- PR #1428 — the entry-file/heal follow-up fix
|
||||
- Issue #1096 — same Settings-install bundled-plugin failure family (missing-bundle symptom for the Paperclip runtime in global npm installs); different root cause
|
||||
- Commit `ff0750cd1` — added CE/Roadmap to the UI list (2 of 4 registrations)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Drift guard for the intentionally duplicated resolvePluginEntryPath.
|
||||
*
|
||||
* The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks
|
||||
* work in tests) while @fusion/core owns the copy used by the dashboard
|
||||
* install/enable routes. This test runs both against real on-disk layouts and
|
||||
* asserts identical results, so a candidate-list change applied to one copy
|
||||
* but not the other fails CI instead of silently diverging.
|
||||
*
|
||||
* No fs mocks here on purpose — vitest module mocks don't reach the
|
||||
* externalized @fusion/core import, so real temp directories are the only
|
||||
* seam that exercises both implementations equally.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js";
|
||||
import { resolvePluginEntryPath as coreResolve } from "@fusion/core";
|
||||
|
||||
describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "entry-path-sync-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function touch(relative: string) {
|
||||
const full = join(dir, relative);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, "// entry\n");
|
||||
}
|
||||
|
||||
const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [
|
||||
{ name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" },
|
||||
{ name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" },
|
||||
{ name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" },
|
||||
{ name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" },
|
||||
{ name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" },
|
||||
{ name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" },
|
||||
{ name: "no entry files", files: ["README.md"], expected: null },
|
||||
];
|
||||
|
||||
for (const layout of layouts) {
|
||||
it(`resolves identically for: ${layout.name}`, () => {
|
||||
for (const f of layout.files) touch(f);
|
||||
const expected = layout.expected === null ? null : join(dir, layout.expected);
|
||||
|
||||
expect(cliResolve(dir)).toBe(expected);
|
||||
expect(coreResolve(dir)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -78,6 +78,9 @@ function resolveBundledPluginDir(pluginId: string): string | null {
|
||||
* Returns null when the directory exists but none of the loadable entry files
|
||||
* are present. Callers must treat that as a missing bundle rather than
|
||||
* persisting a directory path that Node cannot import.
|
||||
*
|
||||
* Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts),
|
||||
* which the dashboard install/enable routes use for the same contract.
|
||||
*/
|
||||
export function resolvePluginEntryPath(pluginDir: string): string | null {
|
||||
const candidates = [
|
||||
|
||||
@@ -763,7 +763,7 @@ export {
|
||||
} from "./plugin-types.js";
|
||||
export { PluginStore } from "./plugin-store.js";
|
||||
export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js";
|
||||
export { PluginLoader } from "./plugin-loader.js";
|
||||
export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js";
|
||||
export { scanPluginSecurity } from "./plugin-security-scan.js";
|
||||
export type { PluginSecurityScanResult, PluginSecurityFinding } from "./plugin-security-scan.js";
|
||||
export type {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* - Error isolation (plugin crashes don't crash the loader)
|
||||
*/
|
||||
|
||||
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
|
||||
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -48,6 +49,35 @@ import { scanPluginSecurity } from "./plugin-security-scan.js";
|
||||
const MINIMUM_FUSION_VERSION = "0.1.0";
|
||||
let moduleImportVersion = 0;
|
||||
|
||||
/**
|
||||
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
|
||||
* does not allow directory imports, so the registered plugin path must be the
|
||||
* explicit file the loader will dynamic-import. Preference order:
|
||||
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
|
||||
* 2. ./dist/index.js (legacy prebuilt fallback)
|
||||
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
|
||||
*
|
||||
* Returns null when the directory exists but none of the loadable entry files
|
||||
* are present. Callers must treat that as a missing/unloadable plugin rather
|
||||
* than persisting a directory path that Node cannot import.
|
||||
*
|
||||
* Keep in sync with resolvePluginEntryPath in the CLI's
|
||||
* bundled-plugin-install.ts, which keeps a local copy so its fs mocks work.
|
||||
*/
|
||||
export function resolvePluginEntryPath(pluginDir: string): string | null {
|
||||
const candidates = [
|
||||
join(pluginDir, "bundled.js"),
|
||||
join(pluginDir, "dist", "index.js"),
|
||||
join(pluginDir, "src", "index.ts"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface PluginLoaderOptions {
|
||||
/** Plugin store for persistence */
|
||||
pluginStore: PluginStore;
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: var(--card);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("../../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
batchUpdateTaskModels: vi.fn(),
|
||||
fetchNodes: vi.fn().mockResolvedValue([]),
|
||||
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
|
||||
}));
|
||||
|
||||
import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api";
|
||||
|
||||
@@ -209,13 +209,13 @@ describe("auto-merge toggle mobile blank regression", () => {
|
||||
expectBoardVisible();
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
board.scrollLeft = 240;
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expect(board.scrollLeft).toBe(0);
|
||||
|
||||
@@ -227,7 +227,7 @@ describe("auto-merge toggle mobile blank regression", () => {
|
||||
board.scrollLeft = 240;
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expectBoardVisible();
|
||||
@@ -248,7 +248,7 @@ describe("auto-merge toggle mobile blank regression", () => {
|
||||
const board = document.querySelector("main.board") as HTMLElement;
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
|
||||
@@ -260,7 +260,7 @@ describe("auto-merge toggle mobile blank regression", () => {
|
||||
board.scrollLeft = 180;
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expectBoardVisible();
|
||||
expect(board.scrollLeft).toBe(0);
|
||||
@@ -270,7 +270,7 @@ describe("auto-merge toggle mobile blank regression", () => {
|
||||
board.scrollLeft = 180;
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expectBoardVisible();
|
||||
expect(board.scrollLeft).toBe(0);
|
||||
@@ -296,7 +296,7 @@ describe("auto-merge toggle mobile blank regression", () => {
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true");
|
||||
@@ -322,7 +322,7 @@ describe("auto-merge toggle mobile blank regression", () => {
|
||||
const board = document.querySelector("main.board") as HTMLElement;
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
|
||||
@@ -332,7 +332,7 @@ describe("auto-merge toggle mobile blank regression", () => {
|
||||
Object.defineProperty(pageShow, "persisted", { configurable: true, value: true });
|
||||
act(() => {
|
||||
window.dispatchEvent(pageShow);
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expectBoardVisible();
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
|
||||
board.scrollLeft = 500;
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expect(board.scrollLeft).toBe(0);
|
||||
expect(raf).toHaveBeenCalled();
|
||||
@@ -130,7 +130,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
|
||||
|
||||
const board = document.querySelector("main.board") as HTMLElement;
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expect(board.scrollLeft).toBe(0);
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
|
||||
window.dispatchEvent(pageShow);
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expect(board.scrollLeft).toBe(0);
|
||||
|
||||
@@ -190,7 +190,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => {
|
||||
window.dispatchEvent(pageShow);
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expect(board.scrollLeft).toBe(500);
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith("pageshow", expect.any(Function));
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("@fusion/core", () => ({
|
||||
summarizeTitle: vi.fn(),
|
||||
AgentStore: vi.fn(),
|
||||
ChatStore: vi.fn(),
|
||||
registerTraitHookImpl: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("resolveFileReferences", () => {
|
||||
|
||||
@@ -280,6 +280,9 @@ describe("POST /api/plugins mode:install — package root path", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -313,11 +316,54 @@ describe("POST /api/plugins mode:install — package root path", () => {
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "my-plugin" }),
|
||||
path: pkgRoot,
|
||||
// Registered path is the loadable entry file inside the package root
|
||||
path: `${pkgRoot}/bundled.js`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to dist/index.js when no bundled.js exists", async () => {
|
||||
const pkgRoot = "/home/user/plugins/my-plugin";
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
mockExistsSync.mockImplementation((p: string) => p === `${pkgRoot}/dist/index.js`);
|
||||
(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(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: `${pkgRoot}/dist/index.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to src/index.ts for workspace-dev packages without build outputs", async () => {
|
||||
const pkgRoot = "/home/user/plugins/my-plugin";
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
mockExistsSync.mockImplementation((p: string) => p === `${pkgRoot}/src/index.ts`);
|
||||
(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(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: `${pkgRoot}/src/index.ts` }),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a dist folder path with valid manifest.json and returns 201", async () => {
|
||||
const distPath = "/home/user/plugins/my-plugin/dist";
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
@@ -338,7 +384,7 @@ describe("POST /api/plugins mode:install — package root path", () => {
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({ id: "my-plugin" });
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: distPath }),
|
||||
expect.objectContaining({ path: `${distPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -434,6 +480,7 @@ describe("POST /api/plugins central persistence integration", () => {
|
||||
if (p === pluginPath || p === `${pluginPath}/manifest.json`) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
});
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
mockReadFile.mockResolvedValueOnce(JSON.stringify(VALID_MANIFEST));
|
||||
|
||||
const app = buildRealApp(pluginStore);
|
||||
@@ -471,6 +518,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -491,7 +541,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
id: "fusion-plugin-dependency-graph",
|
||||
name: "Dependency Graph",
|
||||
};
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-dependency-graph/manifest.json"));
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-dependency-graph/manifest.json") || p.endsWith("bundled.js"));
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p.includes("fusion-plugin-dependency-graph")) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
@@ -512,7 +562,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "fusion-plugin-dependency-graph" }),
|
||||
path: expect.stringContaining("fusion-plugin-dependency-graph"),
|
||||
path: expect.stringMatching(/fusion-plugin-dependency-graph[\\/]bundled\.js$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -523,7 +573,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
id: "fusion-plugin-reports",
|
||||
name: "Reports",
|
||||
};
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-reports/manifest.json"));
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-reports/manifest.json") || p.endsWith("bundled.js"));
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p.includes("fusion-plugin-reports")) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
@@ -544,7 +594,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "fusion-plugin-reports" }),
|
||||
path: expect.stringContaining("fusion-plugin-reports"),
|
||||
path: expect.stringMatching(/fusion-plugin-reports[\\/]bundled\.js$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -557,7 +607,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
};
|
||||
// Only the staged bundled copy under dist/plugins exists — the
|
||||
// cwd-relative path must miss so the bundled fallback is exercised.
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-compound-engineering/manifest.json"));
|
||||
mockExistsSync.mockImplementation((p: string) =>
|
||||
p.includes("dist/plugins/fusion-plugin-compound-engineering/manifest.json")
|
||||
|| p.includes("dist/plugins/fusion-plugin-compound-engineering/bundled.js"));
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p.includes("dist/plugins/fusion-plugin-compound-engineering")) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
@@ -575,10 +627,12 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
// The registered path must be the loadable entry FILE, not the
|
||||
// package directory — the loader rejects directory imports.
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "fusion-plugin-compound-engineering" }),
|
||||
path: expect.stringContaining("fusion-plugin-compound-engineering"),
|
||||
path: expect.stringMatching(/fusion-plugin-compound-engineering[\\/]bundled\.js$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -591,7 +645,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
};
|
||||
// Only the staged bundled copy under dist/plugins exists — the
|
||||
// cwd-relative path must miss so the bundled fallback is exercised.
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-cli-printing-press/manifest.json"));
|
||||
mockExistsSync.mockImplementation((p: string) =>
|
||||
p.includes("dist/plugins/fusion-plugin-cli-printing-press/manifest.json")
|
||||
|| p.includes("dist/plugins/fusion-plugin-cli-printing-press/bundled.js"));
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p.includes("dist/plugins/fusion-plugin-cli-printing-press")) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
@@ -612,7 +668,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "fusion-plugin-cli-printing-press" }),
|
||||
path: expect.stringContaining("fusion-plugin-cli-printing-press"),
|
||||
path: expect.stringMatching(/fusion-plugin-cli-printing-press[\\/]bundled\.js$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -648,7 +704,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/plugins mode:install — negative paths", () => {
|
||||
describe("POST /api/plugins/:id/enable — legacy directory path heal", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
let store: TaskStore;
|
||||
@@ -669,6 +725,110 @@ describe("POST /api/plugins mode:install — negative paths", () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
it("re-points a directory plugin path at its loadable entry before loading", async () => {
|
||||
// Legacy registration stored the package directory; the loader rejects
|
||||
// directory imports, so enable must heal the path first.
|
||||
const dirPath = "/home/user/plugins/my-plugin";
|
||||
mockStatSync.mockReturnValue({ isDirectory: () => true });
|
||||
mockExistsSync.mockImplementation((p: string) => p === `${dirPath}/bundled.js`);
|
||||
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: dirPath,
|
||||
});
|
||||
(pluginStore.updatePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: `${dirPath}/bundled.js`,
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins/my-plugin/enable", {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(pluginStore.updatePlugin).toHaveBeenCalledWith("my-plugin", { path: `${dirPath}/bundled.js` });
|
||||
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
|
||||
});
|
||||
|
||||
it("heals directory paths in createPluginRouter's enable handler too", async () => {
|
||||
const dirPath = "/home/user/plugins/my-plugin";
|
||||
mockStat.mockResolvedValue({ isDirectory: () => true });
|
||||
mockExistsSync.mockImplementation((p: string) => p === `${dirPath}/bundled.js`);
|
||||
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: dirPath,
|
||||
});
|
||||
(pluginStore.updatePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: `${dirPath}/bundled.js`,
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader));
|
||||
const res = await REQUEST(app, "POST", "/api/plugins/my-plugin/enable", {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(pluginStore.updatePlugin).toHaveBeenCalledWith("my-plugin", { path: `${dirPath}/bundled.js` });
|
||||
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
|
||||
});
|
||||
|
||||
it("leaves file paths untouched on enable", async () => {
|
||||
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
||||
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: "/home/user/plugins/my-plugin/bundled.js",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins/my-plugin/enable", {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(pluginStore.updatePlugin).not.toHaveBeenCalled();
|
||||
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();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
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 400 when the package has no loadable entry file", async () => {
|
||||
const pkgRoot = "/home/user/plugins/my-plugin";
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
// Manifest resolves, but no bundled.js / dist/index.js / src/index.ts exists.
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: pkgRoot,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("no loadable entry file");
|
||||
expect(pluginStore.registerPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 when path does not exist", async () => {
|
||||
mockAccess.mockRejectedValue(new Error("not found"));
|
||||
|
||||
@@ -835,6 +995,9 @@ describe("POST /api/plugins mode:install — manifest validation edge cases", ()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -921,6 +1084,9 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore({
|
||||
registerPlugin: vi.fn().mockResolvedValue(INSTALLED_PLUGIN),
|
||||
});
|
||||
@@ -954,7 +1120,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: parentPath }),
|
||||
expect.objectContaining({ path: `${parentPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -974,7 +1140,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: parentPath }),
|
||||
expect.objectContaining({ path: `${parentPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -994,7 +1160,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: parentPath }),
|
||||
expect.objectContaining({ path: `${parentPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1032,9 +1198,9 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
// Should use the dist dir path since it has its own manifest
|
||||
// Should use the dist dir entry since it has its own manifest
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: distPath }),
|
||||
expect.objectContaining({ path: `${distPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1049,6 +1215,9 @@ describe("GET /api/plugins/dashboard-views", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -1166,6 +1335,9 @@ describe("GET /api/plugins/ui-slots", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -1335,6 +1507,9 @@ describe("GET /api/plugins/ui-contributions", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -1842,6 +2017,9 @@ describe("GET /api/plugins/runtimes", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
|
||||
@@ -75,6 +75,7 @@ vi.mock("@fusion/core", () => {
|
||||
isEphemeralAgent: (agent: { metadata?: Record<string, unknown> }) =>
|
||||
agent?.metadata?.agentKind === "task-worker",
|
||||
deterministicGuardLocks: new Map(),
|
||||
registerTraitHookImpl: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
PluginStore,
|
||||
PluginContext,
|
||||
} from "@fusion/core";
|
||||
import { validatePluginManifest } from "@fusion/core";
|
||||
import { resolvePluginEntryPath, validatePluginManifest } from "@fusion/core";
|
||||
import {
|
||||
ApiError,
|
||||
badRequest,
|
||||
@@ -251,7 +251,16 @@ export function createPluginRouter(
|
||||
if (source.path) {
|
||||
const resolved = await resolvePluginManifest(source.path);
|
||||
manifest = resolved.manifest;
|
||||
installPath = resolved.manifestDir;
|
||||
// Register the loadable entry FILE, not the package directory — Node
|
||||
// ESM cannot import directories, so the loader rejects directory paths.
|
||||
const entryPath = resolvePluginEntryPath(resolved.manifestDir);
|
||||
if (!entryPath) {
|
||||
throw badRequest(
|
||||
`Plugin at ${resolved.manifestDir} has no loadable entry file `
|
||||
+ "(expected bundled.js, dist/index.js, or src/index.ts)",
|
||||
);
|
||||
}
|
||||
installPath = entryPath;
|
||||
} else if (source.package) {
|
||||
// npm packages not yet supported
|
||||
throw badRequest("Installing plugins from npm packages is not yet implemented");
|
||||
@@ -298,6 +307,20 @@ export function createPluginRouter(
|
||||
// Enable in store
|
||||
let plugin = await pluginStore.enablePlugin(id);
|
||||
|
||||
// Heal legacy registrations that stored the package directory instead of
|
||||
// a loadable entry file (Node ESM cannot import directories). Mirrors the
|
||||
// heal in routes.ts's enable handler and the CLI's startup heal.
|
||||
try {
|
||||
if ((await stat(plugin.path)).isDirectory()) {
|
||||
const entryPath = resolvePluginEntryPath(plugin.path);
|
||||
if (entryPath) {
|
||||
plugin = await pluginStore.updatePlugin(id, { path: entryPath });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Path missing or unreadable — let loadPlugin surface the real error.
|
||||
}
|
||||
|
||||
// Start the plugin
|
||||
try {
|
||||
await pluginLoader.loadPlugin(id);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
listAgentMemoryFiles,
|
||||
readAgentMemoryFile,
|
||||
resolvePlanningSettingsModel,
|
||||
resolvePluginEntryPath,
|
||||
resolveProjectDefaultModel,
|
||||
resolveTitleSummarizerSettingsModel,
|
||||
writeAgentMemoryFile,
|
||||
@@ -3618,10 +3619,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Resolve manifest — supports package root and dist-folder selections
|
||||
const { manifestDir, manifest } = await resolvePluginManifest(manifestPathForInstall);
|
||||
|
||||
// Register the loadable entry FILE, not the package directory — Node ESM
|
||||
// cannot import directories, so the loader rejects directory paths.
|
||||
const entryPath = resolvePluginEntryPath(manifestDir);
|
||||
if (!entryPath) {
|
||||
throw badRequest(
|
||||
`Plugin at ${manifestDir} has no loadable entry file `
|
||||
+ "(expected bundled.js, dist/index.js, or src/index.ts)",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const plugin = await pluginStore.registerPlugin({
|
||||
manifest,
|
||||
path: manifestDir,
|
||||
path: entryPath,
|
||||
...(typeof aiScanOnLoad === "boolean" ? { aiScanOnLoad } : {}),
|
||||
});
|
||||
|
||||
@@ -3668,6 +3679,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
let plugin = await pluginStore.enablePlugin(id);
|
||||
|
||||
// Heal legacy registrations that stored the package directory instead of
|
||||
// a loadable entry file (Node ESM cannot import directories). Mirrors the
|
||||
// CLI's startup heal in ensureBundledPluginInstalled.
|
||||
try {
|
||||
if (nodeFs.statSync(plugin.path).isDirectory()) {
|
||||
const entryPath = resolvePluginEntryPath(plugin.path);
|
||||
if (entryPath) {
|
||||
plugin = await pluginStore.updatePlugin(id, { path: entryPath });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Path missing or unreadable — let loadPlugin surface the real error.
|
||||
}
|
||||
|
||||
// Start the plugin if loader is available
|
||||
if (options?.pluginLoader) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user