FN-7637: port bundled-plugin auto-install into @fusion/core for the desktop runtime

Move the host-agnostic bundled-plugin auto-install logic (manifest loading, entry-path
resolution, install/update/enable flow) out of the CLI package into @fusion/core so the
desktop embedded runtime can auto-install bundled runtime plugins without depending on
the CLI package; the CLI module becomes a thin adapter that supplies its own bundle-dir
resolution to the shared helper.

- Add packages/core/src/plugins/bundled-plugin-install.ts with the shared, host-agnostic
  ensureBundledPluginInstalled / ensureBundledDependencyGraphPluginInstalled /
  ensureBundledCursorRuntimePluginInstalled implementation and BUNDLED_PLUGIN_IDS/
  isBundledPluginId/resolvePluginEntryPath, exported from @fusion/core's index.
- Slim packages/cli/src/plugins/bundled-plugin-install.ts to a CLI-specific
  candidate-bundle-dir resolver that delegates to @fusion/core and re-exports the same
  public surface dashboard.ts/serve.ts/daemon.ts already depend on.
- Remove the now-redundant packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts
  (coverage moved with the implementation to @fusion/core).
- Add packages/desktop/src/bundled-plugin-dirs.ts to resolve each bundled plugin's staged
  package directory via import.meta.resolve, mirroring the CLI's dist/plugins/<id> resolver.
- Wire local-runtime.ts and local-server.ts to call ensureBundledPluginInstalled before
  loadAllPlugins() and expose a lazy-install callback for PUT /api/plugins/:id/settings,
  mirroring the CLI dashboard command's startup auto-install pass.
- Update docs/PLUGIN_AUTHORING.md to describe the shared bundled-plugin-install location.

Files changed:
 docs/PLUGIN_AUTHORING.md                           |  11 +
 .../__tests__/bundled-plugin-install.test.ts       | 619 ++-------------------
 .../resolve-plugin-entry-path-sync.test.ts         |  97 ----
 packages/cli/src/plugins/bundled-plugin-install.ts | 250 +--------
 packages/core/src/index.ts                         |   8 +
 .../__tests__/bundled-plugin-install.test.ts       | 391 +++++++++++++
 .../core/src/plugins/bundled-plugin-install.ts     | 186 +++++++
 .../src/__tests__/bundled-plugin-dirs.test.ts      |  59 ++
 .../desktop/src/__tests__/local-runtime.test.ts    | 183 +++++-
 .../desktop/src/__tests__/local-server.test.ts     |  96 +++-
 packages/desktop/src/bundled-plugin-dirs.ts        |  61 ++
 packages/desktop/src/local-runtime.ts              |  66 ++-
 packages/desktop/src/local-server.ts               |  36 +-
 13 files changed, 1171 insertions(+), 892 deletions(-)

Fusion-Task-Id: FN-7637

Fusion-Task-Lineage: 953c5b82-a079-4600-b3af-45c974cd5014

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 09:13:07 -07:00
parent 42009cfdb9
commit 26f22861fa
13 changed files with 1181 additions and 902 deletions

View File

@@ -775,6 +775,17 @@ Bundled plugins shipped in `@runfusion/fusion` are tracked by the staged bundled
This catches stale `dist/` drift: `resolvePluginEntryPath` prefers `bundled.js` and compiled `dist/index.js` before falling back to `src/index.ts`, while per-plugin `dist/` is gitignored and can lag behind source edits. If `bundled-plugin-freshness` reports `dist is stale relative to src`, run `pnpm build` from the workspace root to regenerate plugin `dist/` outputs and staged CLI plugin artifacts before rerunning tests.
### Bundled-plugin auto-install: CLI and desktop
<!-- FNXC:PluginLoader 2026-07-07-00:00: FN-7637 ported bundled-plugin auto-install into a shared, host-agnostic @fusion/core helper so the desktop embedded runtime auto-installs bundled runtime plugins the same way the CLI dashboard/serve/daemon commands do. Documented here so plugin authors and host maintainers know both hosts share one code path and only differ in bundle-directory resolution. -->
The install/update/fail-soft-load logic for `BUNDLED_PLUGIN_IDS` (Dependency Graph, Hermes, OpenClaw, Paperclip, Cursor, CLI Printing Press, Compound Engineering, Linear Import, Reports, WhatsApp Chat, Roadmap) lives once in `packages/core/src/plugins/bundled-plugin-install.ts` as `ensureBundledPluginInstalled(pluginStore, pluginLoader, pluginId, getCandidatePluginDirs)`. The only host-specific input is `getCandidatePluginDirs` — the ordered list of directories to probe for a plugin's `manifest.json` — because the CLI and desktop stage bundled plugins differently:
- **CLI** (`packages/cli/src/plugins/bundled-plugin-install.ts`): resolves `<cli>/dist/plugins/<manifest-id>/` (plus source/dev fallbacks) from its own `import.meta.url`. The CLI module is now a thin adapter that supplies this resolver and re-exports the same public surface (`ensureBundledPluginInstalled`, `ensureBundledDependencyGraphPluginInstalled`, `ensureBundledCursorRuntimePluginInstalled`, `isBundledPluginId`, `BUNDLED_PLUGIN_IDS`, `resolvePluginEntryPath`) that `dashboard.ts`, `serve.ts`, and `daemon.ts` already depend on — no behavior change for CLI hosts.
- **Desktop** (`packages/desktop/src/bundled-plugin-dirs.ts`): resolves each manifest id (`fusion-plugin-<short-name>`) to its staged `@fusion-plugin-examples/<short-name>` npm package directory via `import.meta.resolve` against the package's `"."` export, which works whether `node_modules` is flat/hoisted (the packaged `pnpm deploy` closure) or nested (workspace dev). These plugins are workspace dependencies of `@fusion/dashboard`, so `packages/desktop/scripts/workspace-tools.ts#stageDesktopDeploy` already materializes their `manifest.json` + `dist/index.js` into the desktop closure — no desktop-specific build/staging changes were needed. A bundled id desktop does not depend on (currently `reports`, `whatsapp-chat`, `linear-import`) resolves to no candidate directories and is correctly reported as `missing-bundle`, matching the CLI's "not found in this build" behavior for an unstaged plugin.
Both `packages/desktop/src/local-runtime.ts` (`createDashboardServerDefault`) and `packages/desktop/src/local-server.ts` (`DesktopLocalServerManager.start`) wire this identically to the CLI `dashboard` command: auto-install the bundled Dependency Graph plugin before `loadAllPlugins()`, and pass an `ensureBundledPluginInstalled` callback into `createServer(...)` options so `PUT /api/plugins/:id/settings` can lazy-install Hermes/OpenClaw/Paperclip/etc. on first Settings save. Both paths run this inside the existing fail-soft try/catch (see the desktop `pluginStore`/`pluginLoader` wiring, FN-7623) so a plugin auto-install failure logs (via the `FUSION_STARTUP_TRACE` `strace` helper in `local-runtime.ts`) but never crashes embedded startup. `packages/desktop` still does not depend on the CLI package (`@runfusion/fusion`) — the shared helper lives entirely in `@fusion/core`.
Runtime host context contract:
- Registered views receive a `context` object from the dashboard host (`PluginDashboardViewContext`).
- Context includes the active `projectId`, current visible `tasks`, optional `workflowSteps`, `openTaskDetail` for launching the native task detail flow, and `openFile(path, options?)` for opening project-relative files in the dashboard's built-in file viewer.

View File

@@ -1,8 +1,17 @@
/**
* FNXC:PluginLoader 2026-07-07-00:00:
* bundled-plugin-install.ts is now a thin CLI adapter that delegates the pure
* install/update/fail-soft-load logic to @fusion/core's host-agnostic shared
* helper (packages/core/src/plugins/bundled-plugin-install.ts) — see that
* package's own test suite for full EnsureBundledResult coverage (installed /
* updated / already-installed / missing-bundle) and resolvePluginEntryPath
* coverage (packages/core/src/__tests__/plugin-loader.test.ts). This file only
* asserts the CLI-specific concern: candidate bundle-directory resolution from
* `import.meta.url`, i.e. the `<cli>/dist/plugins/<id>` staged-runtime layout
* and its dev/source fallbacks (FN-7637).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── Mocks ────────────────────────────────────────────────────────────
// vi.mock factories are hoisted, so we use vi.hoisted() for mock references.
const { mockExistsSync, mockReaddirSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } =
vi.hoisted(() => ({
mockExistsSync: vi.fn<(path: string) => boolean>(),
@@ -28,183 +37,59 @@ vi.mock("node:fs/promises", () => ({
copyFile: mockCopyFile,
}));
vi.mock("@fusion/core", () => ({
validatePluginManifest: mockValidatePluginManifest,
}));
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return { ...actual, validatePluginManifest: mockValidatePluginManifest };
});
// Import SUT after mocks are in place
import {
BUNDLED_PLUGIN_IDS,
ensureBundledDependencyGraphPluginInstalled,
ensureBundledCursorRuntimePluginInstalled,
ensureBundledPluginInstalled,
resolvePluginEntryPath,
} from "../bundled-plugin-install.js";
// ── Helpers ──────────────────────────────────────────────────────────
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
const REPORTS_PLUGIN_ID = "fusion-plugin-reports";
const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press";
const COMPOUND_ENGINEERING_PLUGIN_ID = "fusion-plugin-compound-engineering";
const LINEAR_IMPORT_PLUGIN_ID = "fusion-plugin-linear-import";
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
return {
id: BUNDLED_PLUGIN_ID,
id: "fusion-plugin-dependency-graph",
name: "Dependency Graph",
version: "0.1.0",
description: "Top-level dependency graph dashboard view",
dashboardViews: [
{
viewId: "graph",
label: "Graph",
componentPath: "./dashboard-view",
icon: "Network",
placement: "more",
order: 40,
},
{ viewId: "graph", label: "Graph", componentPath: "./dashboard-view", icon: "Network", placement: "more", order: 40 },
],
...overrides,
};
}
interface PluginLike {
id: string;
name: string;
version: string;
description?: string;
path: string;
enabled: boolean;
state: string;
settings: Record<string, unknown>;
dependencies?: string[];
createdAt: string;
updatedAt: string;
}
function makePlugin(overrides?: Partial<PluginLike>): PluginLike {
return {
id: BUNDLED_PLUGIN_ID,
name: "Dependency Graph",
version: "0.1.0",
description: "Top-level dependency graph dashboard view",
path: "", // callers should set this
enabled: true,
state: "installed",
settings: {},
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makePluginStore() {
const plugins = new Map<string, PluginLike>();
const plugins = new Map<string, { path: string; version: string; enabled: boolean; id: string }>();
return {
getPlugin: vi.fn(async (id: string) => {
const plugin = plugins.get(id);
if (!plugin)
throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
if (!plugin) throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
return { ...plugin };
}),
registerPlugin: vi.fn(async (input: { manifest: unknown; path: string }) => {
const manifest = input.manifest as ReturnType<typeof makeManifest>;
const plugin = makePlugin({
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
path: input.path,
});
plugins.set(manifest.id, plugin);
const plugin = { id: manifest.id ?? "", version: manifest.version ?? "0.0.0", path: input.path, enabled: true };
plugins.set(plugin.id, plugin);
return plugin;
}),
updatePlugin: vi.fn(async (id: string, updates: Record<string, unknown>) => {
const plugin = plugins.get(id);
if (!plugin) throw new Error(`Plugin "${id}" not found`);
const updated = { ...plugin, ...updates, updatedAt: new Date().toISOString() };
const updated = { ...plugin, ...updates };
plugins.set(id, updated);
return updated;
}),
/** Directly inject a plugin record for test setup */
_inject(plugin: PluginLike) {
plugins.set(plugin.id, { ...plugin });
},
};
}
function makePluginLoader() {
return {
loadPlugin: vi.fn(async () => {}),
unloadPlugin: vi.fn(async () => {}),
getLoadedPlugins: vi.fn(() => new Map()),
isPluginLoaded: vi.fn(() => false),
};
return { loadPlugin: vi.fn(async () => {}) };
}
/**
* Setup: bundled manifest exists at the first candidate path and is valid.
* The resolver's first candidate includes "dist/plugins/..." when running from source.
*/
function setupBundleExists(manifestOverrides?: Partial<{ id: string; version: string }>) {
const manifest = makeManifest(manifestOverrides);
mockExistsSync.mockImplementation((p: string) => {
if (typeof p !== "string") return false;
if (p.endsWith("manifest.json") && p.includes("dist")) return true;
if (p.includes("dist") && (p.endsWith("/bundled.js") || p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"))) {
return true;
}
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
return manifest;
}
/** Setup: no bundled manifest found on any candidate path. */
function setupBundleMissing() {
mockExistsSync.mockReturnValue(false);
}
/** Setup: bundled manifest found but invalid. */
function setupBundleInvalid() {
mockExistsSync.mockImplementation((p: string) => {
if (typeof p === "string" && p.endsWith("manifest.json") && p.includes("dist")) return true;
return false;
});
const badManifest = { id: "bad" };
mockReadFile.mockResolvedValue(JSON.stringify(badManifest));
mockValidatePluginManifest.mockReturnValue({
valid: false,
errors: ["Missing required field: name"],
});
}
/**
* Probe the resolver to determine the actual resolved bundled path.
* Registers the plugin and captures the path from the registerPlugin call.
*/
async function getResolvedBundledPath(): Promise<string> {
setupBundleExists();
const probeStore = makePluginStore();
const probeLoader = makePluginLoader();
await ensureBundledDependencyGraphPluginInstalled(
probeStore as unknown as import("@fusion/core").PluginStore,
probeLoader as unknown as import("@fusion/core").PluginLoader,
);
const call = probeStore.registerPlugin.mock.calls[0];
const path = (call?.[0] as { path: string })?.path ?? "";
expect(path.endsWith(".js") || path.endsWith(".ts")).toBe(true);
return path;
}
// ── Tests ────────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
mockReaddirSync.mockReturnValue([{ name: "index.ts", isDirectory: () => false }]);
@@ -213,54 +98,16 @@ beforeEach(() => {
mockCopyFile.mockResolvedValue();
});
describe("resolvePluginEntryPath", () => {
it("prefers bundled.js when both bundled and source entries exist", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/bundled.js"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
});
it("prefers bundled.js when source entry is unavailable", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/bundled.js"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
});
it("prefers src/index.ts when bundled.js is unavailable and src is newer than dist", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
mockStatSync.mockImplementation((p: string) => ({
isDirectory: () => false,
mtimeMs: p.endsWith("/dist/index.js") ? 1 : 2,
}));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
});
it("prefers dist/index.js when bundled.js is unavailable and dist is newer", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
mockStatSync.mockImplementation((p: string) => ({
isDirectory: () => false,
mtimeMs: p.endsWith("/dist/index.js") ? 2 : 1,
}));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
});
it("prefers dist/index.js when bundled.js is unavailable and mtimes are equal", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
mockStatSync.mockImplementation(() => ({ isDirectory: () => false, mtimeMs: 1 }));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
});
it("falls back to src/index.ts for workspace-dev plugins without build outputs", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
});
it("returns null when no loadable entry file exists", () => {
mockExistsSync.mockReturnValue(false);
expect(resolvePluginEntryPath("/tmp/plugin")).toBeNull();
describe("bundled plugin id set", () => {
it("re-exports the full BUNDLED_PLUGIN_IDS set from @fusion/core", () => {
expect(BUNDLED_PLUGIN_IDS).toContain("fusion-plugin-dependency-graph");
expect(BUNDLED_PLUGIN_IDS).toContain("fusion-plugin-hermes-runtime");
expect(BUNDLED_PLUGIN_IDS.length).toBeGreaterThan(0);
});
});
describe("ensureBundledDependencyGraphPluginInstalled", () => {
it("installs paperclip runtime from bundled dist/plugins layout (global install regression)", async () => {
describe("CLI candidate bundle-directory resolution", () => {
it("installs from the bundled/global runtime layout (<cli>/dist/plugins/<id>/bundled.js — global install regression)", async () => {
const PAPERCLIP_PLUGIN_ID = "fusion-plugin-paperclip-runtime";
const globalDistPluginRoot = `/opt/homebrew/lib/node_modules/@runfusion/fusion/dist/plugins/${PAPERCLIP_PLUGIN_ID}`;
@@ -286,26 +133,23 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
stat: mockFsStat,
copyFile: mockCopyFile,
}));
vi.doMock("@fusion/core", () => ({
validatePluginManifest: mockValidatePluginManifest,
}));
vi.doMock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return { ...actual, validatePluginManifest: mockValidatePluginManifest };
});
const store = makePluginStore();
const loader = makePluginLoader();
const { ensureBundledPluginInstalled: ensureFromBundledBuild } = await import("../bundled-plugin-install.js");
const result = await ensureFromBundledBuild(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
PAPERCLIP_PLUGIN_ID,
);
const result = await ensureFromBundledBuild(store as never, loader as never, PAPERCLIP_PLUGIN_ID);
expect(result).toBe("installed");
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
expect(registerCall.path.endsWith(`/fusion-plugin-paperclip-runtime/bundled.js`)).toBe(true);
});
it("falls back to dev dist/plugins candidate when bundled-runtime candidate is absent", async () => {
it("falls back to the dev dist/plugins candidate when the bundled-runtime candidate is absent", async () => {
const PAPERCLIP_PLUGIN_ID = "fusion-plugin-paperclip-runtime";
mockExistsSync.mockImplementation((p: string) => {
if (typeof p !== "string") return false;
@@ -321,412 +165,59 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
PAPERCLIP_PLUGIN_ID,
);
const result = await ensureBundledPluginInstalled(store as never, loader as never, PAPERCLIP_PLUGIN_ID);
expect(result).toBe("installed");
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
expect(registerCall.path).toContain(`/dist/plugins/${PAPERCLIP_PLUGIN_ID}/src/index.ts`);
});
it("includes roadmap plugin in bundled plugin ids", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(ROADMAP_PLUGIN_ID);
});
it("includes CLI printing press plugin in bundled plugin ids", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(CLI_PRINTING_PRESS_PLUGIN_ID);
});
it("includes reports plugin in bundled plugin ids", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID);
});
it("includes compound engineering plugin in bundled plugin ids", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(COMPOUND_ENGINEERING_PLUGIN_ID);
});
it("includes Linear import plugin in bundled plugin ids", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(LINEAR_IMPORT_PLUGIN_ID);
});
it("fresh install: registers and loads the plugin when not in DB", async () => {
setupBundleExists();
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledOnce();
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: BUNDLED_PLUGIN_ID }),
}),
);
// Fresh install → enabled by default → should be loaded
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("already installed with matching path/version → returns already-installed without DB writes", async () => {
// First probe to get the actual resolved path
const bundledPath = await getResolvedBundledPath();
vi.clearAllMocks();
const manifest = setupBundleExists();
const store = makePluginStore();
const loader = makePluginLoader();
// Inject a plugin that matches the current bundle path and version
store._inject(makePlugin({ path: bundledPath, version: manifest.version }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("already-installed");
expect(store.updatePlugin).not.toHaveBeenCalled();
expect(store.registerPlugin).not.toHaveBeenCalled();
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("already installed with stale path → updates path to current bundled path", async () => {
const bundledPath = await getResolvedBundledPath();
const OLD_PATH = "/old/cli/dist/plugins/fusion-plugin-dependency-graph/bundled.js";
vi.clearAllMocks();
const manifest = setupBundleExists();
const store = makePluginStore();
const loader = makePluginLoader();
// Plugin registered with the OLD path, but current version
store._inject(makePlugin({ path: OLD_PATH, version: manifest.version }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalledWith(
BUNDLED_PLUGIN_ID,
expect.objectContaining({ path: bundledPath }),
);
// Plugin was enabled → should be loaded
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("already installed with stale version → updates version to current manifest version", async () => {
const bundledPath = await getResolvedBundledPath();
vi.clearAllMocks();
const manifest = setupBundleExists({ version: "0.2.0" });
const store = makePluginStore();
const loader = makePluginLoader();
// Plugin registered with old version but same path
store._inject(makePlugin({ path: bundledPath, version: "0.1.0" }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalledWith(
BUNDLED_PLUGIN_ID,
expect.objectContaining({ version: "0.2.0" }),
);
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("disabled plugin → path/version updated but plugin NOT loaded (user choice respected)", async () => {
setupBundleExists({ version: "0.2.0" });
const store = makePluginStore();
const loader = makePluginLoader();
// Plugin explicitly disabled by user with stale version
// Use a path that definitely won't match the resolved path
store._inject(makePlugin({ path: "/stale/path/plugin", version: "0.1.0", enabled: false }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalled();
// User disabled the plugin → should NOT be loaded
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("migrates an existing directory-backed install to the resolved entry file", async () => {
const bundledPath = await getResolvedBundledPath();
const staleDirectoryPath = "/old/cli/dist/plugins/fusion-plugin-dependency-graph";
vi.clearAllMocks();
setupBundleExists();
mockStatSync.mockImplementation((path: string) => ({
isDirectory: () => path === staleDirectoryPath,
}));
const store = makePluginStore();
const loader = makePluginLoader();
store._inject(makePlugin({ path: staleDirectoryPath }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalledWith(
BUNDLED_PLUGIN_ID,
expect.objectContaining({ path: bundledPath }),
);
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("returns missing-bundle when manifest exists but no loadable entry file exists", async () => {
mockExistsSync.mockImplementation((p: string) => typeof p === "string" && p.endsWith("manifest.json") && p.includes("dist"));
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest()));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("missing-bundle");
expect(store.registerPlugin).not.toHaveBeenCalled();
expect(store.updatePlugin).not.toHaveBeenCalled();
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("missing bundle (no bundled manifest found) → returns missing-bundle without error", async () => {
setupBundleMissing();
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("missing-bundle");
expect(store.registerPlugin).not.toHaveBeenCalled();
expect(store.updatePlugin).not.toHaveBeenCalled();
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("invalid bundled manifest → throws descriptive error", async () => {
setupBundleInvalid();
const store = makePluginStore();
const loader = makePluginLoader();
await expect(
ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
),
).rejects.toThrow("Invalid plugin manifest");
});
it("registers Cursor runtime through the dedicated helper", async () => {
const manifest = makeManifest({ id: CURSOR_PLUGIN_ID, name: "Cursor Runtime" });
it("dedicated Cursor runtime helper resolves through the same CLI candidate dirs", async () => {
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(CURSOR_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(CURSOR_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest({ id: CURSOR_PLUGIN_ID, name: "Cursor Runtime" })));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledCursorRuntimePluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: CURSOR_PLUGIN_ID }) }),
);
});
it("registers Linear import plugin via generic bundled installer", async () => {
const manifest = makeManifest({ id: LINEAR_IMPORT_PLUGIN_ID, name: "Linear Import" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(LINEAR_IMPORT_PLUGIN_ID)) return true;
if (p.endsWith("/bundled.js") && p.includes(LINEAR_IMPORT_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
LINEAR_IMPORT_PLUGIN_ID,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: LINEAR_IMPORT_PLUGIN_ID }) }),
);
});
it("registers roadmap plugin via generic bundled installer", async () => {
const manifest = makeManifest({ id: ROADMAP_PLUGIN_ID, name: "Roadmaps" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(ROADMAP_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(ROADMAP_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
ROADMAP_PLUGIN_ID,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: ROADMAP_PLUGIN_ID }) }),
);
});
it("registers reports plugin via generic bundled installer", async () => {
const manifest = makeManifest({ id: REPORTS_PLUGIN_ID, name: "Reports" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(REPORTS_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(REPORTS_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
REPORTS_PLUGIN_ID,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: REPORTS_PLUGIN_ID }) }),
);
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
expect(registerCall.path).toContain(REPORTS_PLUGIN_ID);
});
it("registers Hermes from bundled.js when bundled, src, and dist entries all exist", async () => {
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(HERMES_PLUGIN_ID)) return true;
if (p.endsWith("/bundled.js") && p.includes(HERMES_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(HERMES_PLUGIN_ID)) return true;
if (p.endsWith("/dist/index.js") && p.includes(HERMES_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
HERMES_PLUGIN_ID,
);
const result = await ensureBundledCursorRuntimePluginInstalled(store as never, loader as never);
expect(result).toBe("installed");
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
expect(registerCall.path).toContain(`${HERMES_PLUGIN_ID}/bundled.js`);
expect(registerCall.path).toContain(CURSOR_PLUGIN_ID);
});
// Heavy integration test: runs esbuild to bundle the real dependency-graph
// plugin and load it through a live PluginLoader. ~18s wall on a fast laptop.
// The other tests in this file cover the install/upgrade logic with mocks;
// this one is gated behind FUSION_RUN_SLOW_TESTS=1 so day-to-day runs stay fast.
it.skipIf(process.env.FUSION_RUN_SLOW_TESTS !== "1")("loads the real bundled dependency graph plugin and persists a started state", async () => {
const { existsSync, mkdtempSync, statSync } = await vi.importActual<typeof import("node:fs")>("node:fs");
const { cp, mkdir, readFile, rm, stat, copyFile } = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { fileURLToPath } = await import("node:url");
const { buildSync } = await import("esbuild");
const { PluginLoader } = await import("../../../../core/src/plugin-loader.ts");
const { PluginStore } = await import("../../../../core/src/plugin-store.ts");
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
const sourceRoot = fileURLToPath(new URL("../../../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
const stagedRoot = fileURLToPath(new URL("../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
const pluginStateRoot = mkdtempSync(join(tmpdir(), "fn4128-bundled-plugin-"));
await rm(stagedRoot, { recursive: true, force: true });
await mkdir(stagedRoot, { recursive: true });
await cp(join(sourceRoot, "manifest.json"), join(stagedRoot, "manifest.json"));
buildSync({
entryPoints: [join(sourceRoot, "src", "index.ts")],
outfile: join(stagedRoot, "bundled.js"),
bundle: true,
format: "esm",
platform: "node",
alias: {
"@fusion/plugin-sdk": join(repoRoot, "packages", "plugin-sdk", "src", "index.ts"),
},
logLevel: "silent",
it("deprecated Dependency Graph helper resolves through the same CLI candidate dirs", async () => {
const DEP_GRAPH_ID = "fusion-plugin-dependency-graph";
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(DEP_GRAPH_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(DEP_GRAPH_ID)) return true;
return false;
});
mockExistsSync.mockImplementation((path: string) => existsSync(path));
mockStatSync.mockImplementation((path: string) => statSync(path));
mockReadFile.mockImplementation((path: string, encoding: string) => readFile(path, encoding as BufferEncoding));
mockFsStat.mockImplementation((path: string) => stat(path));
mockCopyFile.mockImplementation((src: string, dest: string) => copyFile(src, dest));
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest()));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
try {
const pluginStore = new PluginStore(pluginStateRoot, { inMemoryDb: true, centralGlobalDir: pluginStateRoot });
await pluginStore.init();
const taskStore = {
getRootDir: () => repoRoot,
logActivity: vi.fn(),
getPluginStore: () => pluginStore,
} as any;
const loader = new PluginLoader({ pluginStore, taskStore });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledDependencyGraphPluginInstalled(pluginStore, loader);
const storedPlugin = await pluginStore.getPlugin(BUNDLED_PLUGIN_ID);
const result = await ensureBundledDependencyGraphPluginInstalled(store as never, loader as never);
expect(result).toBe("installed");
expect(storedPlugin.path.endsWith("/fusion-plugin-dependency-graph/bundled.js")).toBe(true);
expect(storedPlugin.state).toBe("started");
expect(storedPlugin.error ?? null).toBeNull();
} finally {
await rm(stagedRoot, { recursive: true, force: true });
await rm(pluginStateRoot, { recursive: true, force: true });
}
}, 60_000);
expect(result).toBe("installed");
});
it("returns missing-bundle when no CLI candidate dir has a manifest", async () => {
mockExistsSync.mockReturnValue(false);
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(store as never, loader as never, "fusion-plugin-roadmap");
expect(result).toBe("missing-bundle");
expect(store.registerPlugin).not.toHaveBeenCalled();
});
});

View File

@@ -1,97 +0,0 @@
/**
* 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, utimesSync } from "node:fs";
import { dirname, 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(dirname(full), { recursive: true });
writeFileSync(full, "// entry\n");
}
const older = new Date("2026-01-01T00:00:00.000Z");
const newer = new Date("2026-01-01T00:01:00.000Z");
const layouts: Array<{
name: string;
files: string[];
expected: string | null;
mtimes?: Record<string, Date>;
}> = [
{ 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 + src, src newer → src/index.ts",
files: ["dist/index.js", "src/index.ts"],
expected: "src/index.ts",
mtimes: { "dist/index.js": older, "src/index.ts": newer },
},
{
name: "dist + src, dist newer → dist/index.js",
files: ["dist/index.js", "src/index.ts"],
expected: "dist/index.js",
mtimes: { "dist/index.js": newer, "src/index.ts": older },
},
{
name: "dist + src, equal mtimes → dist/index.js",
files: ["dist/index.js", "src/index.ts"],
expected: "dist/index.js",
mtimes: { "dist/index.js": older, "src/index.ts": older },
},
{
name: "dist + src, non-index src file newer → src/index.ts",
files: ["dist/index.js", "src/index.ts", "src/settings.ts"],
expected: "src/index.ts",
mtimes: { "dist/index.js": older, "src/index.ts": older, "src/settings.ts": newer },
},
{
name: "bundled.js + dist + src, src newer → bundled.js",
files: ["bundled.js", "dist/index.js", "src/index.ts"],
expected: "bundled.js",
mtimes: { "dist/index.js": older, "src/index.ts": newer },
},
{ 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);
for (const [file, mtime] of Object.entries(layout.mtimes ?? {})) {
utimesSync(join(dir, file), mtime, mtime);
}
const expected = layout.expected === null ? null : join(dir, layout.expected);
expect(cliResolve(dir)).toBe(expected);
expect(coreResolve(dir)).toBe(expected);
});
}
});

View File

@@ -1,37 +1,30 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
/**
* FNXC:PluginLoader 2026-07-07-00:00:
* Bundled-plugin auto-install logic was ported to @fusion/core
* (packages/core/src/plugins/bundled-plugin-install.ts) so the desktop embedded
* runtime can auto-install bundled runtime plugins without depending on this CLI
* package (FN-7637). This module is now a thin, behavior-preserving CLI adapter:
* it supplies the CLI-specific candidate bundle-directory resolution
* (`<cli>/dist/plugins/<id>` staged layout, resolved from `import.meta.url`) to
* the shared helper and re-exports the same public surface `dashboard.ts`,
* `serve.ts`, and `daemon.ts` already depend on. `resolvePluginEntryPath` is
* re-exported directly from `@fusion/core` (no local duplicate) since the
* shared helper already delegates to it.
*/
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { validatePluginManifest, type PluginInstallation, type PluginLoader, type PluginManifest, type PluginStore } from "@fusion/core";
import {
ensureBundledCursorRuntimePluginInstalled as coreEnsureBundledCursorRuntimePluginInstalled,
ensureBundledDependencyGraphPluginInstalled as coreEnsureBundledDependencyGraphPluginInstalled,
ensureBundledPluginInstalled as coreEnsureBundledPluginInstalled,
type EnsureBundledResult,
type PluginLoader,
type PluginStore,
} from "@fusion/core";
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
export const BUNDLED_PLUGIN_IDS = [
"fusion-plugin-dependency-graph",
"fusion-plugin-reports",
"fusion-plugin-whatsapp-chat",
"fusion-plugin-roadmap",
"fusion-plugin-hermes-runtime",
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-cli-printing-press",
"fusion-plugin-compound-engineering",
"fusion-plugin-linear-import",
] as const;
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
export function isBundledPluginId(id: string): id is BundledPluginId {
return (BUNDLED_PLUGIN_IDS as readonly string[]).includes(id);
}
export type EnsureBundledResult =
| "installed"
| "updated"
| "already-installed"
| "missing-bundle";
export { BUNDLED_PLUGIN_IDS, isBundledPluginId, resolvePluginEntryPath } from "@fusion/core";
export type { BundledPluginId, EnsureBundledResult } from "@fusion/core";
function getCandidatePluginDirs(pluginId: string): string[] {
const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -48,199 +41,12 @@ function getCandidatePluginDirs(pluginId: string): string[] {
];
}
async function loadManifest(pluginDir: string): Promise<PluginManifest> {
const manifestPath = join(pluginDir, "manifest.json");
const content = await readFile(manifestPath, "utf-8");
const manifest = JSON.parse(content);
const validation = validatePluginManifest(manifest);
if (!validation.valid) {
throw new Error(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
}
return manifest;
}
function resolveBundledPluginDir(pluginId: string): string | null {
for (const path of getCandidatePluginDirs(pluginId)) {
if (existsSync(join(path, "manifest.json"))) {
return path;
}
}
return null;
}
/**
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
* does not allow directory imports, so we must register the explicit file the
* loader will dynamic-import. Resolution keeps ./bundled.js unconditional
* because production npm tarballs ship that esbuild-bundled entry. In
* dev/worktree contexts where no bundle exists, ./dist/index.js remains the
* prebuilt fallback unless any file under ./src/ is newer than dist/index.js;
* then ./src/index.ts wins so stale gitignored dist output cannot mask a source
* fix (FN-6615/FN-6596).
*
* FNXC:PluginLoader 2026-06-17-19:20:
* Prefer fresher src over stale dist only when bundled.js is absent. This keeps
* production tarballs on their bundled entry while preventing dev/worktree runs
* from silently loading old gitignored build output after a source fix.
*
* 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.
*/
function newestSourceMtimeMs(srcDir: string): number | null {
let newest = Number.NEGATIVE_INFINITY;
function visit(dir: string): boolean {
const entries = (() => {
try {
return readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
} catch {
return null;
}
})();
if (!entries) return false;
for (const entry of entries) {
const entryPath = join(dir, entry.name);
let entryStat: ReturnType<typeof statSync>;
try {
entryStat = statSync(entryPath);
} catch {
return false;
}
if (entryStat.isDirectory()) {
if (!visit(entryPath)) return false;
continue;
}
if (entryStat.mtimeMs > newest) {
newest = entryStat.mtimeMs;
}
}
return true;
}
return visit(srcDir) && newest !== Number.NEGATIVE_INFINITY ? newest : null;
}
function isSourceNewerThanDist(srcDir: string, distIndexPath: string): boolean {
try {
const distMtimeMs = statSync(distIndexPath).mtimeMs;
const srcMtimeMs = newestSourceMtimeMs(srcDir);
return srcMtimeMs !== null && srcMtimeMs > distMtimeMs;
} catch {
return false;
}
}
export function resolvePluginEntryPath(pluginDir: string): string | null {
const bundledPath = join(pluginDir, "bundled.js");
if (existsSync(bundledPath)) {
return bundledPath;
}
const distIndexPath = join(pluginDir, "dist", "index.js");
const srcDir = join(pluginDir, "src");
const srcIndexPath = join(srcDir, "index.ts");
const hasDist = existsSync(distIndexPath);
const hasSrc = existsSync(srcIndexPath);
if (hasDist && hasSrc) {
return isSourceNewerThanDist(srcDir, distIndexPath) ? srcIndexPath : distIndexPath;
}
if (hasDist) {
return distIndexPath;
}
if (hasSrc) {
return srcIndexPath;
}
return null;
}
function isDirectoryPath(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
export async function ensureBundledPluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
pluginId: string,
): Promise<EnsureBundledResult> {
let existingPlugin: PluginInstallation | null = null;
try {
existingPlugin = await pluginStore.getPlugin(pluginId);
} catch {
// Continue; plugin not installed yet.
}
const bundledDir = resolveBundledPluginDir(pluginId);
if (!bundledDir) {
return "missing-bundle";
}
const manifest = await loadManifest(bundledDir);
const entryPath = resolvePluginEntryPath(bundledDir);
if (!entryPath) {
console.warn(`[plugins] Bundled plugin "${pluginId}" is missing a loadable entry file in ${bundledDir}`);
return "missing-bundle";
}
if (existingPlugin) {
const existingPathIsDirectory = isDirectoryPath(existingPlugin.path);
const pathChanged = existingPathIsDirectory || existingPlugin.path !== entryPath;
const versionChanged = existingPlugin.version !== manifest.version;
if (!pathChanged && !versionChanged) {
if (existingPlugin.enabled) {
try {
await pluginLoader.loadPlugin(existingPlugin.id);
} catch (err) {
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
}
}
return "already-installed";
}
await pluginStore.updatePlugin(pluginId, {
...(pathChanged ? { path: entryPath } : {}),
...(versionChanged ? { version: manifest.version } : {}),
});
if (existingPlugin.enabled) {
try {
await pluginLoader.loadPlugin(existingPlugin.id);
} catch (err) {
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
}
}
return "updated";
}
const plugin = await pluginStore.registerPlugin({
manifest,
path: entryPath,
});
if (plugin.enabled) {
try {
await pluginLoader.loadPlugin(plugin.id);
} catch (err) {
console.warn("[plugins] failed to load bundled plugin", plugin.id, err);
}
}
return "installed";
return coreEnsureBundledPluginInstalled(pluginStore, pluginLoader, pluginId, getCandidatePluginDirs);
}
/**
@@ -251,12 +57,12 @@ export async function ensureBundledDependencyGraphPluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID);
return coreEnsureBundledDependencyGraphPluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs);
}
export async function ensureBundledCursorRuntimePluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID);
return coreEnsureBundledCursorRuntimePluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs);
}

View File

@@ -1233,6 +1233,14 @@ export type {
export { PluginStore } from "./plugin-store.js";
export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js";
export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js";
export {
BUNDLED_PLUGIN_IDS,
isBundledPluginId,
ensureBundledPluginInstalled,
ensureBundledDependencyGraphPluginInstalled,
ensureBundledCursorRuntimePluginInstalled,
} from "./plugins/bundled-plugin-install.js";
export type { BundledPluginId, EnsureBundledResult, BundledPluginDirResolver } from "./plugins/bundled-plugin-install.js";
export { scanPluginSecurity } from "./plugin-security-scan.js";
export type { PluginSecurityScanResult, PluginSecurityFinding } from "./plugin-security-scan.js";
export type {

View File

@@ -0,0 +1,391 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── Mocks ────────────────────────────────────────────────────────────
// vi.mock factories are hoisted, so we use vi.hoisted() for mock references.
const { mockExistsSync, mockReaddirSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } =
vi.hoisted(() => ({
mockExistsSync: vi.fn<(path: string) => boolean>(),
mockReaddirSync: vi.fn<
(path: string, options: { withFileTypes: true; encoding: "utf8" }) => Array<{ name: string; isDirectory: () => boolean }>
>(),
mockStatSync: vi.fn<(path: string) => { isDirectory: () => boolean; mtimeMs?: number }>(),
mockReadFile: vi.fn<(path: string, encoding: string) => Promise<string>>(),
mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(),
mockCopyFile: vi.fn<(src: string, dest: string) => Promise<void>>(),
mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(),
}));
vi.mock("node:fs", () => ({
existsSync: mockExistsSync,
readdirSync: mockReaddirSync,
statSync: mockStatSync,
}));
vi.mock("node:fs/promises", () => ({
readFile: mockReadFile,
stat: mockFsStat,
copyFile: mockCopyFile,
}));
vi.mock("../../plugin-types.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../plugin-types.js")>();
return { ...actual, validatePluginManifest: mockValidatePluginManifest };
});
// Import SUT after mocks are in place
import {
BUNDLED_PLUGIN_IDS,
ensureBundledDependencyGraphPluginInstalled,
ensureBundledCursorRuntimePluginInstalled,
ensureBundledPluginInstalled,
type BundledPluginDirResolver,
} from "../bundled-plugin-install.js";
// ── Helpers ──────────────────────────────────────────────────────────
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
const REPORTS_PLUGIN_ID = "fusion-plugin-reports";
const LINEAR_IMPORT_PLUGIN_ID = "fusion-plugin-linear-import";
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
return {
id: BUNDLED_PLUGIN_ID,
name: "Dependency Graph",
version: "0.1.0",
description: "Top-level dependency graph dashboard view",
dashboardViews: [
{
viewId: "graph",
label: "Graph",
componentPath: "./dashboard-view",
icon: "Network",
placement: "more",
order: 40,
},
],
...overrides,
};
}
interface PluginLike {
id: string;
name: string;
version: string;
description?: string;
path: string;
enabled: boolean;
state: string;
settings: Record<string, unknown>;
dependencies?: string[];
createdAt: string;
updatedAt: string;
}
function makePlugin(overrides?: Partial<PluginLike>): PluginLike {
return {
id: BUNDLED_PLUGIN_ID,
name: "Dependency Graph",
version: "0.1.0",
description: "Top-level dependency graph dashboard view",
path: "", // callers should set this
enabled: true,
state: "installed",
settings: {},
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makePluginStore() {
const plugins = new Map<string, PluginLike>();
return {
getPlugin: vi.fn(async (id: string) => {
const plugin = plugins.get(id);
if (!plugin)
throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
return { ...plugin };
}),
registerPlugin: vi.fn(async (input: { manifest: unknown; path: string }) => {
const manifest = input.manifest as ReturnType<typeof makeManifest>;
const plugin = makePlugin({
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
path: input.path,
});
plugins.set(manifest.id, plugin);
return plugin;
}),
updatePlugin: vi.fn(async (id: string, updates: Record<string, unknown>) => {
const plugin = plugins.get(id);
if (!plugin) throw new Error(`Plugin "${id}" not found`);
const updated = { ...plugin, ...updates, updatedAt: new Date().toISOString() };
plugins.set(id, updated);
return updated;
}),
/** Directly inject a plugin record for test setup */
_inject(plugin: PluginLike) {
plugins.set(plugin.id, { ...plugin });
},
};
}
function makePluginLoader() {
return {
loadPlugin: vi.fn(async () => {}),
unloadPlugin: vi.fn(async () => {}),
getLoadedPlugins: vi.fn(() => new Map()),
isPluginLoaded: vi.fn(() => false),
};
}
/** A CLI-shaped resolver: single candidate dir per plugin id (mirrors <cli>/dist/plugins/<id>). */
function cliShapedResolver(pluginId: string): string[] {
return [`/cli/dist/plugins/${pluginId}`];
}
/** A desktop-shaped resolver: single candidate dir per plugin id (mirrors node_modules/@fusion-plugin-examples/<short>). */
function desktopShapedResolver(pluginId: string): string[] {
const shortName = pluginId.replace(/^fusion-plugin-/, "");
return [`/desktop/node_modules/@fusion-plugin-examples/${shortName}`];
}
function setupBundleExists(resolver: BundledPluginDirResolver, manifestOverrides?: Partial<{ id: string; version: string }>) {
const manifest = makeManifest(manifestOverrides);
const [dir] = resolver(manifest.id ?? BUNDLED_PLUGIN_ID);
mockExistsSync.mockImplementation((p: string) => {
if (typeof p !== "string") return false;
return p === `${dir}/manifest.json` || p === `${dir}/src/index.ts`;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
return { manifest, dir };
}
function setupBundleMissing() {
mockExistsSync.mockReturnValue(false);
}
beforeEach(() => {
vi.clearAllMocks();
mockReaddirSync.mockReturnValue([{ name: "index.ts", isDirectory: () => false }]);
mockStatSync.mockImplementation(() => ({ isDirectory: () => false, mtimeMs: 0 }));
mockFsStat.mockImplementation(async () => ({ isDirectory: () => false }));
mockCopyFile.mockResolvedValue();
});
// ── Tests ────────────────────────────────────────────────────────────
describe("ensureBundledPluginInstalled (host-agnostic shared helper)", () => {
it("includes the full bundled plugin id set", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(ROADMAP_PLUGIN_ID);
expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID);
expect(BUNDLED_PLUGIN_IDS).toContain(LINEAR_IMPORT_PLUGIN_ID);
expect(BUNDLED_PLUGIN_IDS).toContain(HERMES_PLUGIN_ID);
});
it("fresh install: registers and loads the plugin when not in DB (CLI-shaped resolver)", async () => {
const { dir } = setupBundleExists(cliShapedResolver);
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: `${dir}/src/index.ts`, manifest: expect.objectContaining({ id: BUNDLED_PLUGIN_ID }) }),
);
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("fresh install: registers and loads the plugin when not in DB (desktop-shaped resolver)", async () => {
const { dir } = setupBundleExists(desktopShapedResolver, { id: HERMES_PLUGIN_ID });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(store as never, loader as never, HERMES_PLUGIN_ID, desktopShapedResolver);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: `${dir}/src/index.ts`, manifest: expect.objectContaining({ id: HERMES_PLUGIN_ID }) }),
);
expect(loader.loadPlugin).toHaveBeenCalledWith(HERMES_PLUGIN_ID);
});
it("already installed with matching path/version → returns already-installed without DB writes", async () => {
const { manifest, dir } = setupBundleExists(cliShapedResolver);
const store = makePluginStore();
const loader = makePluginLoader();
store._inject(makePlugin({ path: `${dir}/src/index.ts`, version: manifest.version }));
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
expect(result).toBe("already-installed");
expect(store.updatePlugin).not.toHaveBeenCalled();
expect(store.registerPlugin).not.toHaveBeenCalled();
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("already installed with stale path → updates path to current bundled path", async () => {
const { manifest, dir } = setupBundleExists(cliShapedResolver);
const store = makePluginStore();
const loader = makePluginLoader();
store._inject(makePlugin({ path: "/old/path/bundled.js", version: manifest.version }));
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalledWith(
BUNDLED_PLUGIN_ID,
expect.objectContaining({ path: `${dir}/src/index.ts` }),
);
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("already installed with stale version → updates version to current manifest version", async () => {
const { dir } = setupBundleExists(cliShapedResolver, { version: "0.2.0" });
const store = makePluginStore();
const loader = makePluginLoader();
store._inject(makePlugin({ path: `${dir}/src/index.ts`, version: "0.1.0" }));
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID, expect.objectContaining({ version: "0.2.0" }));
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("disabled plugin → path/version updated but plugin NOT loaded (user choice respected)", async () => {
setupBundleExists(cliShapedResolver, { version: "0.2.0" });
const store = makePluginStore();
const loader = makePluginLoader();
store._inject(makePlugin({ path: "/stale/path/plugin", version: "0.1.0", enabled: false }));
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalled();
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("migrates an existing directory-backed install to the resolved entry file", async () => {
const { dir } = setupBundleExists(cliShapedResolver);
const staleDirectoryPath = `${dir}`;
mockStatSync.mockImplementation((path: string) => ({
isDirectory: () => path === staleDirectoryPath,
}));
const store = makePluginStore();
const loader = makePluginLoader();
store._inject(makePlugin({ path: staleDirectoryPath }));
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalledWith(
BUNDLED_PLUGIN_ID,
expect.objectContaining({ path: `${dir}/src/index.ts` }),
);
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("returns missing-bundle when manifest exists but no loadable entry file exists", async () => {
const dir = "/cli/dist/plugins/fusion-plugin-dependency-graph";
mockExistsSync.mockImplementation((p: string) => typeof p === "string" && p === `${dir}/manifest.json`);
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest()));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
expect(result).toBe("missing-bundle");
expect(store.registerPlugin).not.toHaveBeenCalled();
expect(store.updatePlugin).not.toHaveBeenCalled();
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("missing bundle (no bundled manifest found anywhere, e.g. desktop closure lacking the package) → returns missing-bundle without error", async () => {
setupBundleMissing();
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(store as never, loader as never, REPORTS_PLUGIN_ID, desktopShapedResolver);
expect(result).toBe("missing-bundle");
expect(store.registerPlugin).not.toHaveBeenCalled();
expect(store.updatePlugin).not.toHaveBeenCalled();
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("invalid bundled manifest → throws descriptive error", async () => {
const dir = "/cli/dist/plugins/fusion-plugin-dependency-graph";
mockExistsSync.mockImplementation((p: string) => typeof p === "string" && p === `${dir}/manifest.json`);
mockReadFile.mockResolvedValue(JSON.stringify({ id: "bad" }));
mockValidatePluginManifest.mockReturnValue({ valid: false, errors: ["Missing required field: name"] });
const store = makePluginStore();
const loader = makePluginLoader();
await expect(
ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver),
).rejects.toThrow("Invalid plugin manifest");
});
it("registers Cursor runtime through the dedicated helper", async () => {
setupBundleExists(cliShapedResolver, { id: CURSOR_PLUGIN_ID });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledCursorRuntimePluginInstalled(store as never, loader as never, cliShapedResolver);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: CURSOR_PLUGIN_ID }) }),
);
});
it("registers Dependency Graph through the deprecated dedicated helper", async () => {
setupBundleExists(cliShapedResolver);
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledDependencyGraphPluginInstalled(store as never, loader as never, cliShapedResolver);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: BUNDLED_PLUGIN_ID }) }),
);
});
it("registers Linear import plugin via generic bundled installer (desktop-shaped resolver)", async () => {
setupBundleExists(desktopShapedResolver, { id: LINEAR_IMPORT_PLUGIN_ID });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(store as never, loader as never, LINEAR_IMPORT_PLUGIN_ID, desktopShapedResolver);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: LINEAR_IMPORT_PLUGIN_ID }) }),
);
});
it("registers roadmap plugin via generic bundled installer", async () => {
setupBundleExists(cliShapedResolver, { id: ROADMAP_PLUGIN_ID });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(store as never, loader as never, ROADMAP_PLUGIN_ID, cliShapedResolver);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: ROADMAP_PLUGIN_ID }) }),
);
});
});

View File

@@ -0,0 +1,186 @@
/**
* FNXC:PluginLoader 2026-07-07-00:00:
* Bundled-plugin auto-install is host-agnostic in @fusion/core. Hosts (the CLI's
* `<cli>/dist/plugins/<id>` staging layout vs the desktop `@fusion-plugin-examples/<short>`
* node_modules layout) supply their own bundle-directory resolution — the ONLY
* host-specific concern — via the `getCandidatePluginDirs` parameter below. This
* lets the identical install/update/fail-soft-load logic run under both the CLI
* `dashboard`/`serve`/`daemon` commands and the desktop embedded runtime
* (`local-runtime.ts` / `local-server.ts`) without `packages/desktop` depending on
* the CLI package (FN-7637; builds on FN-7623's desktop pluginStore/pluginLoader
* wiring). Everything below except `getCandidatePluginDirs`/`resolveBundledPluginDir`
* is a direct, behavior-preserving port of `packages/cli/src/plugins/bundled-plugin-install.ts`.
*/
import { existsSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { validatePluginManifest } from "../plugin-types.js";
import type { PluginInstallation, PluginManifest } from "../plugin-types.js";
import { resolvePluginEntryPath } from "../plugin-loader.js";
import type { PluginLoader } from "../plugin-loader.js";
import type { PluginStore } from "../plugin-store.js";
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
export const BUNDLED_PLUGIN_IDS = [
"fusion-plugin-dependency-graph",
"fusion-plugin-reports",
"fusion-plugin-whatsapp-chat",
"fusion-plugin-roadmap",
"fusion-plugin-hermes-runtime",
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-cli-printing-press",
"fusion-plugin-compound-engineering",
"fusion-plugin-linear-import",
] as const;
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
export function isBundledPluginId(id: string): id is BundledPluginId {
return (BUNDLED_PLUGIN_IDS as readonly string[]).includes(id);
}
export type EnsureBundledResult =
| "installed"
| "updated"
| "already-installed"
| "missing-bundle";
/** Host-supplied resolver: given a plugin id, return candidate directories to probe for `manifest.json`. */
export type BundledPluginDirResolver = (pluginId: string) => string[];
async function loadManifest(pluginDir: string): Promise<PluginManifest> {
const manifestPath = join(pluginDir, "manifest.json");
const content = await readFile(manifestPath, "utf-8");
const manifest = JSON.parse(content);
const validation = validatePluginManifest(manifest);
if (!validation.valid) {
throw new Error(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
}
return manifest;
}
function resolveBundledPluginDir(pluginId: string, getCandidatePluginDirs: BundledPluginDirResolver): string | null {
for (const path of getCandidatePluginDirs(pluginId)) {
if (existsSync(join(path, "manifest.json"))) {
return path;
}
}
return null;
}
function isDirectoryPath(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
/**
* Ensure a bundled runtime plugin is registered (and, if enabled, loaded) in the
* given `pluginStore`/`pluginLoader`. The only host-specific input is
* `getCandidatePluginDirs`, which returns the ordered list of directories to probe
* for a `manifest.json` for the given plugin id — the CLI supplies its
* `<cli>/dist/plugins/<id>` search paths, desktop supplies its
* `node_modules/@fusion-plugin-examples/<short>` resolution. See `resolvePluginEntryPath`
* (also in `@fusion/core`) for the loadable-entry-file selection this helper reuses
* rather than re-duplicating.
*/
export async function ensureBundledPluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
pluginId: string,
getCandidatePluginDirs: BundledPluginDirResolver,
): Promise<EnsureBundledResult> {
let existingPlugin: PluginInstallation | null = null;
try {
existingPlugin = await pluginStore.getPlugin(pluginId);
} catch {
// Continue; plugin not installed yet.
}
const bundledDir = resolveBundledPluginDir(pluginId, getCandidatePluginDirs);
if (!bundledDir) {
return "missing-bundle";
}
const manifest = await loadManifest(bundledDir);
const entryPath = resolvePluginEntryPath(bundledDir);
if (!entryPath) {
console.warn(`[plugins] Bundled plugin "${pluginId}" is missing a loadable entry file in ${bundledDir}`);
return "missing-bundle";
}
if (existingPlugin) {
const existingPathIsDirectory = isDirectoryPath(existingPlugin.path);
const pathChanged = existingPathIsDirectory || existingPlugin.path !== entryPath;
const versionChanged = existingPlugin.version !== manifest.version;
if (!pathChanged && !versionChanged) {
if (existingPlugin.enabled) {
try {
await pluginLoader.loadPlugin(existingPlugin.id);
} catch (err) {
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
}
}
return "already-installed";
}
await pluginStore.updatePlugin(pluginId, {
...(pathChanged ? { path: entryPath } : {}),
...(versionChanged ? { version: manifest.version } : {}),
});
if (existingPlugin.enabled) {
try {
await pluginLoader.loadPlugin(existingPlugin.id);
} catch (err) {
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
}
}
return "updated";
}
const plugin = await pluginStore.registerPlugin({
manifest,
path: entryPath,
});
if (plugin.enabled) {
try {
await pluginLoader.loadPlugin(plugin.id);
} catch (err) {
console.warn("[plugins] failed to load bundled plugin", plugin.id, err);
}
}
return "installed";
}
/**
* @deprecated Use {@link ensureBundledPluginInstalled} with the explicit plugin id.
* Kept for backwards compatibility with existing call sites.
*/
export async function ensureBundledDependencyGraphPluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
getCandidatePluginDirs: BundledPluginDirResolver,
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID, getCandidatePluginDirs);
}
export async function ensureBundledCursorRuntimePluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
getCandidatePluginDirs: BundledPluginDirResolver,
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID, getCandidatePluginDirs);
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { pathToFileURL } from "node:url";
import { resolveDesktopBundlePluginDirs } from "../bundled-plugin-dirs.js";
/*
* FNXC:DesktopRuntime 2026-07-07-12:30:
* FN-7637: bundled-plugin-dirs.ts's resolveDesktopBundlePluginDirs takes an injectable
* `resolveSpecifier` (defaulting to import.meta.resolve) purely so unit tests can drive it
* deterministically without needing the real @fusion-plugin-examples/* packages built/staged
* in this dev worktree. Asserts the manifest-id -> npm-package-short-name transform and the
* "walk up two directories from the resolved dist/index.js entry to the package root"
* derivation that the desktop wiring in local-runtime.ts / local-server.ts depends on.
*/
describe("resolveDesktopBundlePluginDirs", () => {
it("maps a manifest id to its @fusion-plugin-examples/<short-name> package and walks up to the package root", () => {
const fakeEntryPath = "/desktop/deploy/node_modules/@fusion-plugin-examples/hermes-runtime/dist/index.js";
let requestedSpecifier: string | undefined;
const dirs = resolveDesktopBundlePluginDirs("fusion-plugin-hermes-runtime", (specifier) => {
requestedSpecifier = specifier;
return pathToFileURL(fakeEntryPath).href;
});
expect(requestedSpecifier).toBe("@fusion-plugin-examples/hermes-runtime");
expect(dirs).toEqual(["/desktop/deploy/node_modules/@fusion-plugin-examples/hermes-runtime"]);
});
it("returns an empty candidate list when the package is not resolvable (e.g. desktop does not bundle it)", () => {
const dirs = resolveDesktopBundlePluginDirs("fusion-plugin-reports", () => {
throw new Error("Cannot find package '@fusion-plugin-examples/reports'");
});
expect(dirs).toEqual([]);
});
it("derives the correct short name for every bundled plugin id shape", () => {
const seen: string[] = [];
const resolver = (specifier: string) => {
seen.push(specifier);
return pathToFileURL(`/x/node_modules/${specifier}/dist/index.js`).href;
};
resolveDesktopBundlePluginDirs("fusion-plugin-dependency-graph", resolver);
resolveDesktopBundlePluginDirs("fusion-plugin-cli-printing-press", resolver);
resolveDesktopBundlePluginDirs("fusion-plugin-openclaw-runtime", resolver);
expect(seen).toEqual([
"@fusion-plugin-examples/dependency-graph",
"@fusion-plugin-examples/cli-printing-press",
"@fusion-plugin-examples/openclaw-runtime",
]);
});
it("resolves for real using the default import.meta.resolve when the package genuinely is not installed", () => {
// No injected resolver: exercises the real default (import.meta.resolve). This package is
// not staged in this dev worktree, so the real resolver throws and this must fail soft to [].
const dirs = resolveDesktopBundlePluginDirs("fusion-plugin-does-not-exist-in-this-worktree");
expect(dirs).toEqual([]);
});
});

View File

@@ -85,6 +85,14 @@ const engineMocks = vi.hoisted(() => {
}));
const createServer = vi.fn(() => ({ listen: vi.fn() }));
// FN-7637: bundled-plugin auto-install mocks proving createDashboardServerDefault wires
// ensureBundledPluginInstalled/isBundledPluginId from @fusion/core into both the startup
// auto-install pass (Dependency Graph before loadAllPlugins) and the createServer(...)
// callback option consumed by PUT /api/plugins/:id/settings.
const ensureBundledPluginInstalled = vi.fn(async () => "installed" as const);
const isBundledPluginId = vi.fn((id: string) => id.startsWith("fusion-plugin-"));
const resolveDesktopBundlePluginDirs = vi.fn((pluginId: string) => [`/desktop/node_modules/@fusion-plugin-examples/${pluginId.replace(/^fusion-plugin-/, "")}`]);
return {
centralCore,
engineManager,
@@ -97,10 +105,19 @@ const engineMocks = vi.hoisted(() => {
pluginStoreInstance,
pluginLoaderInstance,
runPluginSchemaInits,
ensureBundledPluginInstalled,
isBundledPluginId,
resolveDesktopBundlePluginDirs,
};
});
vi.mock("@fusion/core", () => ({ CentralCore: engineMocks.CentralCore, PluginLoader: engineMocks.PluginLoader }));
vi.mock("@fusion/core", () => ({
CentralCore: engineMocks.CentralCore,
PluginLoader: engineMocks.PluginLoader,
ensureBundledPluginInstalled: engineMocks.ensureBundledPluginInstalled,
isBundledPluginId: engineMocks.isBundledPluginId,
}));
vi.mock("../bundled-plugin-dirs.js", () => ({ resolveDesktopBundlePluginDirs: engineMocks.resolveDesktopBundlePluginDirs }));
vi.mock("@fusion/dashboard", () => ({ createServer: engineMocks.createServer }));
vi.mock("@fusion/engine", () => ({
ProjectEngineManager: engineMocks.ProjectEngineManager,
@@ -563,4 +580,168 @@ describe("LocalRuntimeManager", () => {
await manager.stopLocal();
});
/*
* FN-7637 symptom verification: before this fix, createDashboardServerDefault never invoked
* ensureBundledPluginInstalled and never passed an ensureBundledPluginInstalled callback into
* createServer(...), so bundled runtime plugins (Dependency Graph, Hermes, OpenClaw, Paperclip, …)
* were never auto-installed on desktop the way the CLI dashboard command auto-installs them.
* Assert the fix holds in BOTH the engine-less (zero-projects) and projects-present startup
* states, since auto-install must run independent of whether a primary engine resolved.
*/
it("auto-installs the bundled Dependency Graph plugin and wires ensureBundledPluginInstalled into createServer when engine-less (zero projects) (FN-7637)", async () => {
const { LocalRuntimeManager } = await import("../local-runtime.ts");
const server = new FakeServer(4545);
engineMocks.createServer.mockReturnValueOnce({
listen: vi.fn(() => {
setTimeout(() => server.emit("listening"), 0);
return server as unknown as Server;
}),
});
const manager = new LocalRuntimeManager({
rootDir: "/repo",
createStore: async () => store,
});
await manager.startLocal();
expect(engineMocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
engineMocks.pluginStoreInstance,
engineMocks.pluginLoaderInstance,
"fusion-plugin-dependency-graph",
engineMocks.resolveDesktopBundlePluginDirs,
);
expect(engineMocks.createServer).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ ensureBundledPluginInstalled: expect.any(Function) }),
);
await manager.stopLocal();
});
it("auto-installs the bundled Dependency Graph plugin and wires ensureBundledPluginInstalled into createServer when a project engine resolved (projects-present) (FN-7637)", async () => {
const { LocalRuntimeManager } = await import("../local-runtime.ts");
engineMocks.centralCore.listProjects.mockResolvedValueOnce([
{ id: "project-1", name: "Repo", path: "/repo", status: "active" },
]);
const server = new FakeServer(4545);
engineMocks.createServer.mockReturnValueOnce({
listen: vi.fn(() => {
setTimeout(() => server.emit("listening"), 0);
return server as unknown as Server;
}),
});
const manager = new LocalRuntimeManager({
rootDir: "/repo",
createStore: async () => store,
});
await manager.startLocal();
expect(engineMocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
engineMocks.pluginStoreInstance,
engineMocks.pluginLoaderInstance,
"fusion-plugin-dependency-graph",
engineMocks.resolveDesktopBundlePluginDirs,
);
expect(engineMocks.createServer).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
engine: expect.anything(),
ensureBundledPluginInstalled: expect.any(Function),
}),
);
await manager.stopLocal();
});
it("the wired ensureBundledPluginInstalled callback delegates to the shared helper for a lazy-install id (FN-7637)", async () => {
const { LocalRuntimeManager } = await import("../local-runtime.ts");
const server = new FakeServer(4545);
engineMocks.createServer.mockReturnValueOnce({
listen: vi.fn(() => {
setTimeout(() => server.emit("listening"), 0);
return server as unknown as Server;
}),
});
const manager = new LocalRuntimeManager({
rootDir: "/repo",
createStore: async () => store,
});
await manager.startLocal();
const callOptions = engineMocks.createServer.mock.calls[0]?.[1] as { ensureBundledPluginInstalled: (id: string) => Promise<boolean> };
engineMocks.ensureBundledPluginInstalled.mockClear();
engineMocks.ensureBundledPluginInstalled.mockResolvedValueOnce("installed");
const result = await callOptions.ensureBundledPluginInstalled("fusion-plugin-hermes-runtime");
expect(result).toBe(true);
expect(engineMocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
engineMocks.pluginStoreInstance,
engineMocks.pluginLoaderInstance,
"fusion-plugin-hermes-runtime",
engineMocks.resolveDesktopBundlePluginDirs,
);
await manager.stopLocal();
});
it("the wired ensureBundledPluginInstalled callback returns false for a missing bundle (FN-7637)", async () => {
const { LocalRuntimeManager } = await import("../local-runtime.ts");
const server = new FakeServer(4545);
engineMocks.createServer.mockReturnValueOnce({
listen: vi.fn(() => {
setTimeout(() => server.emit("listening"), 0);
return server as unknown as Server;
}),
});
const manager = new LocalRuntimeManager({
rootDir: "/repo",
createStore: async () => store,
});
await manager.startLocal();
const callOptions = engineMocks.createServer.mock.calls[0]?.[1] as { ensureBundledPluginInstalled: (id: string) => Promise<boolean> };
engineMocks.ensureBundledPluginInstalled.mockResolvedValueOnce("missing-bundle");
const result = await callOptions.ensureBundledPluginInstalled("fusion-plugin-reports");
expect(result).toBe(false);
await manager.stopLocal();
});
it("does not wire ensureBundledPluginInstalled into createServer when the plugin subsystem fails to init (fail-soft) (FN-7637)", async () => {
const { LocalRuntimeManager } = await import("../local-runtime.ts");
engineMocks.pluginStoreInstance.init.mockRejectedValueOnce(new Error("plugin db locked"));
const server = new FakeServer(4545);
engineMocks.createServer.mockReturnValueOnce({
listen: vi.fn(() => {
setTimeout(() => server.emit("listening"), 0);
return server as unknown as Server;
}),
});
const manager = new LocalRuntimeManager({
rootDir: "/repo",
createStore: async () => store,
});
const status = await manager.startLocal();
expect(status).toMatchObject({ source: "embedded-local", state: "running", port: 4545 });
expect(engineMocks.createServer).toHaveBeenCalledWith(
expect.anything(),
expect.not.objectContaining({ ensureBundledPluginInstalled: expect.anything() }),
);
await manager.stopLocal();
});
});

View File

@@ -46,6 +46,14 @@ const mocks = vi.hoisted(() => {
return pluginLoaderInstance;
});
// FN-7637: bundled-plugin auto-install mocks proving local-server.ts wires
// ensureBundledPluginInstalled/isBundledPluginId from @fusion/core into both the startup
// auto-install pass (Dependency Graph before loadAllPlugins) and the createServer(...)
// callback option consumed by PUT /api/plugins/:id/settings.
const ensureBundledPluginInstalled = vi.fn(async () => "installed" as const);
const isBundledPluginId = vi.fn((id: string) => id.startsWith("fusion-plugin-"));
const resolveDesktopBundlePluginDirs = vi.fn((pluginId: string) => [`/desktop/node_modules/@fusion-plugin-examples/${pluginId.replace(/^fusion-plugin-/, "")}`]);
const store = {
init: vi.fn(async () => undefined),
watch: vi.fn(async () => undefined),
@@ -127,10 +135,20 @@ const mocks = vi.hoisted(() => {
runPluginSchemaInits,
seedDashboardProviders,
seedDashboardProvidersDispose,
ensureBundledPluginInstalled,
isBundledPluginId,
resolveDesktopBundlePluginDirs,
};
});
vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore, CentralCore: mocks.CentralCore, PluginLoader: mocks.PluginLoader }));
vi.mock("@fusion/core", () => ({
TaskStore: mocks.TaskStore,
CentralCore: mocks.CentralCore,
PluginLoader: mocks.PluginLoader,
ensureBundledPluginInstalled: mocks.ensureBundledPluginInstalled,
isBundledPluginId: mocks.isBundledPluginId,
}));
vi.mock("../bundled-plugin-dirs.js", () => ({ resolveDesktopBundlePluginDirs: mocks.resolveDesktopBundlePluginDirs }));
vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServer }));
vi.mock("@fusion/engine", () => ({
ProjectEngineManager: mocks.ProjectEngineManager,
@@ -312,4 +330,80 @@ describe("DesktopLocalServerManager", () => {
expect.not.objectContaining({ pluginStore: expect.anything() }),
);
});
/*
* FN-7637 symptom verification: before this fix, DesktopLocalServerManager.start() never invoked
* ensureBundledPluginInstalled and never passed an ensureBundledPluginInstalled callback into
* createServer(...), so bundled runtime plugins (Dependency Graph, Hermes, OpenClaw, Paperclip, …)
* were never auto-installed on desktop the way they are under the CLI dashboard command. Assert the
* fix: the startup pass calls the shared helper for the bundled Dependency Graph id using the
* desktop bundle-dir resolver, and createServer receives a callable ensureBundledPluginInstalled
* option.
*/
it("auto-installs the bundled Dependency Graph plugin at startup and wires ensureBundledPluginInstalled into createServer (FN-7637)", async () => {
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
await manager.start();
expect(mocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
mocks.pluginStoreInstance,
mocks.pluginLoaderInstance,
"fusion-plugin-dependency-graph",
mocks.resolveDesktopBundlePluginDirs,
);
expect(mocks.createServer).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ ensureBundledPluginInstalled: expect.any(Function) }),
);
});
it("the wired ensureBundledPluginInstalled callback delegates to the shared helper for a lazy-install id", async () => {
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
await manager.start();
const callOptions = mocks.createServer.mock.calls[0]?.[1] as { ensureBundledPluginInstalled: (id: string) => Promise<boolean> };
mocks.ensureBundledPluginInstalled.mockClear();
mocks.ensureBundledPluginInstalled.mockResolvedValueOnce("installed");
const result = await callOptions.ensureBundledPluginInstalled("fusion-plugin-hermes-runtime");
expect(result).toBe(true);
expect(mocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
mocks.pluginStoreInstance,
mocks.pluginLoaderInstance,
"fusion-plugin-hermes-runtime",
mocks.resolveDesktopBundlePluginDirs,
);
});
it("the wired ensureBundledPluginInstalled callback returns false for a missing bundle", async () => {
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
await manager.start();
const callOptions = mocks.createServer.mock.calls[0]?.[1] as { ensureBundledPluginInstalled: (id: string) => Promise<boolean> };
mocks.ensureBundledPluginInstalled.mockResolvedValueOnce("missing-bundle");
const result = await callOptions.ensureBundledPluginInstalled("fusion-plugin-reports");
expect(result).toBe(false);
});
it("does not wire ensureBundledPluginInstalled into createServer when the plugin subsystem fails to init (fail-soft)", async () => {
mocks.pluginStoreInstance.init.mockRejectedValueOnce(new Error("plugin db locked"));
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
const runtime = await manager.start();
expect(runtime.port).toBe(4545);
expect(mocks.createServer).toHaveBeenCalledWith(
expect.anything(),
expect.not.objectContaining({ ensureBundledPluginInstalled: expect.anything() }),
);
});
});

View File

@@ -0,0 +1,61 @@
/**
* FNXC:DesktopRuntime 2026-07-07-12:30:
* FN-7637: bundle-directory resolution is the ONLY host-specific input the shared
* @fusion/core `ensureBundledPluginInstalled` helper needs (see packages/core/src/plugins/bundled-plugin-install.ts).
* The CLI stages bundled plugins under `<cli>/dist/plugins/<manifest-id>/`; desktop's
* packaged closure instead carries the same plugins as `@fusion-plugin-examples/<short-name>`
* workspace packages (dependencies of `@fusion/dashboard`, materialized into the desktop
* `pnpm deploy` closure by `packages/desktop/scripts/workspace-tools.ts#stageDesktopDeploy` —
* see FN-7637 Step 1 investigation, task document "decision"). Every bundled plugin's
* manifest `id` is `fusion-plugin-<short-name>` and its npm package name is
* `@fusion-plugin-examples/<short-name>` — a mechanical transform, not a hardcoded table.
*
* Resolution uses `import.meta.resolve` against the package's declared "." export
* (which points at `./dist/index.js`) rather than manual node_modules traversal, so it
* works correctly under both flat/hoisted (desktop deploy) and nested (workspace dev)
* `node_modules` layouts. The manifest.json sits at the package root (one directory above
* `dist/`), so the resolved dist/index.js path is walked up two directories to get the
* candidate bundle directory. A plugin that desktop does not depend on (e.g. `reports`,
* `whatsapp-chat`, `linear-import` — not `@fusion/dashboard` deps) has no resolvable
* package and this returns an empty candidate list, which `ensureBundledPluginInstalled`
* correctly reports as `missing-bundle` (parity with the CLI's "not found in this build").
*/
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
/** Manifest id (e.g. "fusion-plugin-hermes-runtime") -> npm package short name (e.g. "hermes-runtime"). */
function toPackageShortName(pluginId: string): string {
return pluginId.replace(/^fusion-plugin-/, "");
}
/**
* Resolve the candidate bundle directory (directory containing `manifest.json`) for a
* bundled plugin id under the desktop/dashboard `node_modules` resolution root. Returns
* an empty array when the corresponding `@fusion-plugin-examples/<short-name>` package is
* not installed in this closure (desktop does not bundle every CLI-bundled plugin).
*
* `resolveSpecifier` defaults to this module's own `import.meta.resolve` (Node exposes it as
* a writable/configurable own property) and is overridable purely for unit testing — each ES
* module has its own distinct `import.meta`, so a caller/test module cannot patch this
* module's resolver from the outside without an injectable seam.
*/
export function resolveDesktopBundlePluginDirs(
pluginId: string,
resolveSpecifier: (specifier: string) => string = import.meta.resolve,
): string[] {
const packageName = `@fusion-plugin-examples/${toPackageShortName(pluginId)}`;
try {
// import.meta.resolve applies Node's package "exports" resolution algorithm without
// requiring the target file to exist on disk, so this works even before `dist/` is built
// in a dev worktree — it only fails (throws) when the package itself isn't resolvable.
const resolvedUrl = resolveSpecifier(packageName);
const resolvedEntryPath = fileURLToPath(resolvedUrl);
// resolvedEntryPath is ".../<package-root>/dist/index.js"; the package root (where
// manifest.json lives) is two directories up.
const packageRoot = dirname(dirname(resolvedEntryPath));
return [packageRoot];
} catch {
return [];
}
}

View File

@@ -4,6 +4,7 @@ import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { resolveDesktopRuntimePrimaryProject } from "./engine-runtime.js";
import { resolveDesktopBundlePluginDirs } from "./bundled-plugin-dirs.js";
/*
* FNXC:DesktopRuntime 2026-07-02-14:35:
@@ -100,7 +101,7 @@ async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
}
async function createDashboardServerDefault(store: TaskStoreLike, rootDir: string): Promise<{ server: Server; cleanup: RuntimeCleanup }> {
const { CentralCore, PluginLoader } = await import("@fusion/core");
const { CentralCore, PluginLoader, ensureBundledPluginInstalled, isBundledPluginId } = await import("@fusion/core");
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine");
@@ -168,19 +169,49 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
* FN-7623: mirror the CLI dashboard command's plugin wiring (packages/cli/src/commands/dashboard.ts)
* — construct the store's PluginStore, build a PluginLoader, load enabled plugins, and run schema-init
* hooks — so the desktop embedded server's registry sub-router mounts (GET /api/plugins/registry) and
* POST /api/plugins install mode works. Bundled-plugin auto-install (Hermes/OpenClaw/Paperclip/Dependency
* Graph) depends on packages/cli/src/plugins/bundled-plugin-install.ts, which is CLI-only and out of
* scope for desktop (desktop must not depend on the CLI package) — see FN-7623 scope note. Failures here
* must not crash embedded startup: the dashboard still needs to boot even if the plugin subsystem can't
* come up (e.g. a corrupt plugin manifest), so this is wrapped and traced rather than left to throw.
* POST /api/plugins install mode works. Failures here must not crash embedded startup: the dashboard
* still needs to boot even if the plugin subsystem can't come up (e.g. a corrupt plugin manifest), so
* this is wrapped and traced rather than left to throw.
*
* FNXC:DesktopRuntime 2026-07-07-12:30:
* FN-7637: bundled-plugin auto-install (Dependency Graph, Hermes, OpenClaw, Paperclip, …) is now
* host-agnostic in @fusion/core's ensureBundledPluginInstalled. The only host-specific input is
* bundle-directory resolution: resolveDesktopBundlePluginDirs (./bundled-plugin-dirs.js) resolves
* each manifest id to its staged `@fusion-plugin-examples/<short-name>` package directory via
* import.meta.resolve, mirroring the CLI's `<cli>/dist/plugins/<id>` resolver
* (packages/cli/src/plugins/bundled-plugin-install.ts). Mirrors the CLI dashboard command's startup
* auto-install pass: install the bundled Dependency Graph plugin before loadAllPlugins() so it is
* enabled/registered before the general load pass runs, and expose the same lazy-install callback the
* CLI wires so PUT /api/plugins/:id/settings can auto-install Hermes/OpenClaw/Paperclip/etc. on first
* save. Cross-reference: local-server.ts carries the matching wiring for the other desktop startup path.
*/
let pluginStore: PluginStoreLike | undefined;
let pluginLoader: InstanceType<typeof PluginLoader> | undefined;
let ensureBundledPluginInstalledCallback: ((pluginId: string) => Promise<boolean>) | undefined;
try {
strace("createDashboardServer: pluginStore.init");
pluginStore = store.getPluginStore();
await pluginStore.init();
pluginLoader = new PluginLoader({ pluginStore: pluginStore as never, taskStore: store as never });
const boundPluginStore = pluginStore;
const boundPluginLoader = pluginLoader;
try {
strace("createDashboardServer: bundled dependency-graph auto-install");
const installStatus = await ensureBundledPluginInstalled(
boundPluginStore as never,
boundPluginLoader,
"fusion-plugin-dependency-graph",
resolveDesktopBundlePluginDirs,
);
strace(`createDashboardServer: bundled dependency-graph auto-install status=${installStatus}`);
} catch (error) {
strace(
`createDashboardServer: bundled dependency-graph auto-install FAILED (non-fatal) — ${error instanceof Error ? error.stack : String(error)}`,
);
}
strace("createDashboardServer: pluginLoader.loadAllPlugins");
const { loaded, errors } = await pluginLoader.loadAllPlugins();
strace(`createDashboardServer: plugins loaded=${loaded} errors=${errors}`);
@@ -188,12 +219,34 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
if (schemaHooks.length > 0) {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
}
ensureBundledPluginInstalledCallback = async (pluginId: string): Promise<boolean> => {
if (!isBundledPluginId(pluginId)) {
strace(`ensureBundledPluginInstalled: unknown bundled plugin id "${pluginId}"`);
return false;
}
try {
const status = await ensureBundledPluginInstalled(boundPluginStore as never, boundPluginLoader, pluginId, resolveDesktopBundlePluginDirs);
if (status === "missing-bundle") {
strace(`ensureBundledPluginInstalled: bundled plugin "${pluginId}" not found in this build`);
return false;
}
strace(`ensureBundledPluginInstalled: bundled plugin "${pluginId}" status=${status}`);
return true;
} catch (error) {
strace(
`ensureBundledPluginInstalled: failed to auto-install "${pluginId}" — ${error instanceof Error ? error.stack : String(error)}`,
);
throw error;
}
};
} catch (error) {
strace(
`createDashboardServer: plugin subsystem init FAILED (non-fatal, dashboard still boots) — ${error instanceof Error ? error.stack : String(error)}`,
);
pluginStore = undefined;
pluginLoader = undefined;
ensureBundledPluginInstalledCallback = undefined;
}
strace("createDashboardServer: createServer");
@@ -204,6 +257,7 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
authStorage: wrappedAuthStorage,
modelRegistry,
...(pluginStore && pluginLoader ? { pluginStore: pluginStore as never, pluginLoader, pluginRunner: pluginLoader } : {}),
...(ensureBundledPluginInstalledCallback ? { ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback } : {}),
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
});

View File

@@ -3,6 +3,7 @@ import { once } from "node:events";
import type { Server } from "node:http";
import { resolveDesktopRuntimePrimaryProject } from "./engine-runtime.js";
import { resolveDesktopBundlePluginDirs } from "./bundled-plugin-dirs.js";
/*
* FNXC:DesktopRuntime 2026-07-07-12:00:
@@ -66,7 +67,7 @@ export class DesktopLocalServerManager {
try {
const { TaskStore } = await import("@fusion/core");
const { CentralCore, PluginLoader } = await import("@fusion/core");
const { CentralCore, PluginLoader, ensureBundledPluginInstalled, isBundledPluginId } = await import("@fusion/core");
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine");
store = new TaskStore(this.rootDir) as TaskStoreLike;
@@ -113,22 +114,54 @@ export class DesktopLocalServerManager {
* build a PluginLoader, load enabled plugins, and run schema-init hooks — so this legacy path's
* registry sub-router mounts and install works too. Fail soft: a broken plugin subsystem must not
* prevent the embedded dashboard from booting.
*
* FNXC:DesktopRuntime 2026-07-07-12:30:
* FN-7637: mirror local-runtime.ts's bundled-plugin auto-install wiring so BOTH desktop startup
* paths auto-install bundled runtime plugins (Dependency Graph, Hermes, OpenClaw, Paperclip, …)
* identically — same shared @fusion/core helper, same resolveDesktopBundlePluginDirs resolver, same
* lazy-install callback exposed to PUT /api/plugins/:id/settings. See local-runtime.ts's matching
* comment for the full rationale.
*/
let pluginStore: PluginStoreLike | undefined;
let pluginLoader: InstanceType<typeof PluginLoader> | undefined;
let ensureBundledPluginInstalledCallback: ((pluginId: string) => Promise<boolean>) | undefined;
try {
pluginStore = store.getPluginStore();
await pluginStore.init();
pluginLoader = new PluginLoader({ pluginStore: pluginStore as never, taskStore: store as never });
const boundPluginStore = pluginStore;
const boundPluginLoader = pluginLoader;
try {
await ensureBundledPluginInstalled(
boundPluginStore as never,
boundPluginLoader,
"fusion-plugin-dependency-graph",
resolveDesktopBundlePluginDirs,
);
} catch {
// Bundled dependency-graph auto-install failure must not block startup (FN-7637, mirrors FN-7623 fail-soft).
}
await pluginLoader.loadAllPlugins();
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
if (schemaHooks.length > 0) {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
}
ensureBundledPluginInstalledCallback = async (pluginId: string): Promise<boolean> => {
if (!isBundledPluginId(pluginId)) {
return false;
}
const status = await ensureBundledPluginInstalled(boundPluginStore as never, boundPluginLoader, pluginId, resolveDesktopBundlePluginDirs);
return status !== "missing-bundle";
};
} catch {
// Plugin subsystem failures must not block embedded dashboard startup (FN-7623).
pluginStore = undefined;
pluginLoader = undefined;
ensureBundledPluginInstalledCallback = undefined;
}
const app = createServer(store as never, {
@@ -138,6 +171,7 @@ export class DesktopLocalServerManager {
authStorage: wrappedAuthStorage,
modelRegistry,
...(pluginStore && pluginLoader ? { pluginStore: pluginStore as never, pluginLoader, pluginRunner: pluginLoader } : {}),
...(ensureBundledPluginInstalledCallback ? { ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback } : {}),
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
});
server = app.listen(0);