diff --git a/.changeset/workflow-graph-editor-and-bundled-plugins.md b/.changeset/workflow-graph-editor-and-bundled-plugins.md
index ebef3b8410..d8b6bb60fc 100644
--- a/.changeset/workflow-graph-editor-and-bundled-plugins.md
+++ b/.changeset/workflow-graph-editor-and-bundled-plugins.md
@@ -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.
diff --git a/docs/solutions/integration-issues/bundled-plugin-registration-drift.md b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md
index b9b485a893..a7973caad5 100644
--- a/docs/solutions/integration-issues/bundled-plugin-registration-drift.md
+++ b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md
@@ -63,6 +63,10 @@ await bundlePluginEntry({
});
```
+## Follow-up failure: directory registered as plugin path
+
+Fixing the fallback surfaced a second, independent bug: 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:
`. 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: the install routes now resolve and register the entry file (helper added to `@fusion/core`), and the enable route heals legacy directory-path rows in place — mirroring the CLI's startup heal.
+
## Why This Works
The Settings card sends a relative `./plugins/` 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.
diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts
index 7ddf24a102..6f5beede25 100644
--- a/packages/cli/src/plugins/bundled-plugin-install.ts
+++ b/packages/cli/src/plugins/bundled-plugin-install.ts
@@ -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 = [
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index dc731acb8f..7d89c6fa08 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -587,7 +587,7 @@ export type {
export { validatePluginManifest, normalizePluginUiContributionSurface, normalizePluginUiContributionDefinition } 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 {
diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts
index 4d06bb6532..c9a5b28472 100644
--- a/packages/core/src/plugin-loader.ts
+++ b/packages/core/src/plugin-loader.ts
@@ -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";
@@ -47,6 +48,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;
diff --git a/packages/dashboard/src/__tests__/plugin-routes.test.ts b/packages/dashboard/src/__tests__/plugin-routes.test.ts
index 62dce6a2f6..b6e7f54eb2 100644
--- a/packages/dashboard/src/__tests__/plugin-routes.test.ts
+++ b/packages/dashboard/src/__tests__/plugin-routes.test.ts
@@ -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,7 +316,8 @@ 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`,
}),
);
});
@@ -338,7 +342,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 +438,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 +476,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 +499,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"));
@@ -523,7 +531,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"));
@@ -557,7 +565,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 +585,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 +603,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 +626,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,6 +662,64 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
});
});
+describe("POST /api/plugins/:id/enable — legacy directory path heal", () => {
+ 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("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).mockResolvedValue({
+ ...INSTALLED_PLUGIN,
+ path: dirPath,
+ });
+ (pluginStore.updatePlugin as ReturnType).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("leaves file paths untouched on enable", async () => {
+ mockStatSync.mockReturnValue({ isDirectory: () => false });
+ (pluginStore.enablePlugin as ReturnType).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;
@@ -655,6 +727,9 @@ describe("POST /api/plugins mode:install — negative paths", () => {
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({
@@ -835,6 +910,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 +999,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 +1035,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 +1055,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 +1075,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 +1113,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 +1130,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 +1250,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 +1422,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 +1932,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({
diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts
index d12cf471d8..883498d78a 100644
--- a/packages/dashboard/src/plugin-routes.ts
+++ b/packages/dashboard/src/plugin-routes.ts
@@ -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");
diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts
index 57ec951ebd..47e8bff7d1 100644
--- a/packages/dashboard/src/routes.ts
+++ b/packages/dashboard/src/routes.ts
@@ -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 {