From 26f22861fa2f0ce7c751111139ca0895f07da800 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 09:13:07 -0700 Subject: [PATCH] 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/ 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) --- docs/PLUGIN_AUTHORING.md | 11 + .../__tests__/bundled-plugin-install.test.ts | 641 ++---------------- .../resolve-plugin-entry-path-sync.test.ts | 97 --- .../cli/src/plugins/bundled-plugin-install.ts | 248 +------ packages/core/src/index.ts | 8 + .../__tests__/bundled-plugin-install.test.ts | 391 +++++++++++ .../src/plugins/bundled-plugin-install.ts | 186 +++++ .../src/__tests__/bundled-plugin-dirs.test.ts | 59 ++ .../src/__tests__/local-runtime.test.ts | 183 ++++- .../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, 1181 insertions(+), 902 deletions(-) delete mode 100644 packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts create mode 100644 packages/core/src/plugins/__tests__/bundled-plugin-install.test.ts create mode 100644 packages/core/src/plugins/bundled-plugin-install.ts create mode 100644 packages/desktop/src/__tests__/bundled-plugin-dirs.test.ts create mode 100644 packages/desktop/src/bundled-plugin-dirs.ts diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index a802ad9e85..f2780b074b 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -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 + + + +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 `/dist/plugins//` (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-`) to its staged `@fusion-plugin-examples/` 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. diff --git a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts index 1c00b173f0..610bd0669f 100644 --- a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts +++ b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts @@ -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 `/dist/plugins/` 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(); + 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; - dependencies?: string[]; - createdAt: string; - updatedAt: string; -} - -function makePlugin(overrides?: Partial): 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(); + const plugins = new Map(); 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; - 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) => { 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 { - 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 (/dist/plugins//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(); + 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("node:fs"); - const { cp, mkdir, readFile, rm, stat, copyFile } = await vi.importActual("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(); + }); }); diff --git a/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts b/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts deleted file mode 100644 index 525a27082f..0000000000 --- a/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts +++ /dev/null @@ -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; - }> = [ - { 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); - }); - } -}); diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index a4c2c224fd..6cb4b18a8f 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -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 + * (`/dist/plugins/` 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 { - 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; - 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 { - 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 { - return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID); + return coreEnsureBundledDependencyGraphPluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs); } export async function ensureBundledCursorRuntimePluginInstalled( pluginStore: PluginStore, pluginLoader: PluginLoader, ): Promise { - return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID); + return coreEnsureBundledCursorRuntimePluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db2fbf4f97..71bf00e303 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/plugins/__tests__/bundled-plugin-install.test.ts b/packages/core/src/plugins/__tests__/bundled-plugin-install.test.ts new file mode 100644 index 0000000000..5d7fe75151 --- /dev/null +++ b/packages/core/src/plugins/__tests__/bundled-plugin-install.test.ts @@ -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>(), + mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(), + mockCopyFile: vi.fn<(src: string, dest: string) => Promise>(), + 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(); + 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; + dependencies?: string[]; + createdAt: string; + updatedAt: string; +} + +function makePlugin(overrides?: Partial): 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(); + 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; + 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) => { + 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 /dist/plugins/). */ +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/). */ +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 }) }), + ); + }); +}); diff --git a/packages/core/src/plugins/bundled-plugin-install.ts b/packages/core/src/plugins/bundled-plugin-install.ts new file mode 100644 index 0000000000..1c72d6a573 --- /dev/null +++ b/packages/core/src/plugins/bundled-plugin-install.ts @@ -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 + * `/dist/plugins/` staging layout vs the desktop `@fusion-plugin-examples/` + * 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 { + 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 + * `/dist/plugins/` search paths, desktop supplies its + * `node_modules/@fusion-plugin-examples/` 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 { + 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 { + return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID, getCandidatePluginDirs); +} + +export async function ensureBundledCursorRuntimePluginInstalled( + pluginStore: PluginStore, + pluginLoader: PluginLoader, + getCandidatePluginDirs: BundledPluginDirResolver, +): Promise { + return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID, getCandidatePluginDirs); +} diff --git a/packages/desktop/src/__tests__/bundled-plugin-dirs.test.ts b/packages/desktop/src/__tests__/bundled-plugin-dirs.test.ts new file mode 100644 index 0000000000..1f039c1489 --- /dev/null +++ b/packages/desktop/src/__tests__/bundled-plugin-dirs.test.ts @@ -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/ 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([]); + }); +}); diff --git a/packages/desktop/src/__tests__/local-runtime.test.ts b/packages/desktop/src/__tests__/local-runtime.test.ts index f7b06e862a..9ac2139658 100644 --- a/packages/desktop/src/__tests__/local-runtime.test.ts +++ b/packages/desktop/src/__tests__/local-runtime.test.ts @@ -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 }; + 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 }; + 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(); + }); }); diff --git a/packages/desktop/src/__tests__/local-server.test.ts b/packages/desktop/src/__tests__/local-server.test.ts index d1556cb065..d5aa0a86bb 100644 --- a/packages/desktop/src/__tests__/local-server.test.ts +++ b/packages/desktop/src/__tests__/local-server.test.ts @@ -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 }; + 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 }; + 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() }), + ); + }); }); diff --git a/packages/desktop/src/bundled-plugin-dirs.ts b/packages/desktop/src/bundled-plugin-dirs.ts new file mode 100644 index 0000000000..4f173e05ef --- /dev/null +++ b/packages/desktop/src/bundled-plugin-dirs.ts @@ -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 `/dist/plugins//`; desktop's + * packaged closure instead carries the same plugins as `@fusion-plugin-examples/` + * 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-` and its npm package name is + * `@fusion-plugin-examples/` — 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/` 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 "...//dist/index.js"; the package root (where + // manifest.json lives) is two directories up. + const packageRoot = dirname(dirname(resolvedEntryPath)); + return [packageRoot]; + } catch { + return []; + } +} diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index c2bc54409e..1e5256c455 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -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 { } 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/` package directory via + * import.meta.resolve, mirroring the CLI's `/dist/plugins/` 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 | undefined; + let ensureBundledPluginInstalledCallback: ((pluginId: string) => Promise) | 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 => { + 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), }); diff --git a/packages/desktop/src/local-server.ts b/packages/desktop/src/local-server.ts index f3f9c7f556..62f4d0ebb2 100644 --- a/packages/desktop/src/local-server.ts +++ b/packages/desktop/src/local-server.ts @@ -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 | undefined; + let ensureBundledPluginInstalledCallback: ((pluginId: string) => Promise) | 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 => { + 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);