FN-7637: port bundled-plugin auto-install into @fusion/core for the desktop runtime
Move the host-agnostic bundled-plugin auto-install logic (manifest loading, entry-path resolution, install/update/enable flow) out of the CLI package into @fusion/core so the desktop embedded runtime can auto-install bundled runtime plugins without depending on the CLI package; the CLI module becomes a thin adapter that supplies its own bundle-dir resolution to the shared helper. - Add packages/core/src/plugins/bundled-plugin-install.ts with the shared, host-agnostic ensureBundledPluginInstalled / ensureBundledDependencyGraphPluginInstalled / ensureBundledCursorRuntimePluginInstalled implementation and BUNDLED_PLUGIN_IDS/ isBundledPluginId/resolvePluginEntryPath, exported from @fusion/core's index. - Slim packages/cli/src/plugins/bundled-plugin-install.ts to a CLI-specific candidate-bundle-dir resolver that delegates to @fusion/core and re-exports the same public surface dashboard.ts/serve.ts/daemon.ts already depend on. - Remove the now-redundant packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts (coverage moved with the implementation to @fusion/core). - Add packages/desktop/src/bundled-plugin-dirs.ts to resolve each bundled plugin's staged package directory via import.meta.resolve, mirroring the CLI's dist/plugins/<id> resolver. - Wire local-runtime.ts and local-server.ts to call ensureBundledPluginInstalled before loadAllPlugins() and expose a lazy-install callback for PUT /api/plugins/:id/settings, mirroring the CLI dashboard command's startup auto-install pass. - Update docs/PLUGIN_AUTHORING.md to describe the shared bundled-plugin-install location. Files changed: docs/PLUGIN_AUTHORING.md | 11 + .../__tests__/bundled-plugin-install.test.ts | 619 ++------------------- .../resolve-plugin-entry-path-sync.test.ts | 97 ---- packages/cli/src/plugins/bundled-plugin-install.ts | 250 +-------- packages/core/src/index.ts | 8 + .../__tests__/bundled-plugin-install.test.ts | 391 +++++++++++++ .../core/src/plugins/bundled-plugin-install.ts | 186 +++++++ .../src/__tests__/bundled-plugin-dirs.test.ts | 59 ++ .../desktop/src/__tests__/local-runtime.test.ts | 183 +++++- .../desktop/src/__tests__/local-server.test.ts | 96 +++- packages/desktop/src/bundled-plugin-dirs.ts | 61 ++ packages/desktop/src/local-runtime.ts | 66 ++- packages/desktop/src/local-server.ts | 36 +- 13 files changed, 1171 insertions(+), 892 deletions(-) Fusion-Task-Id: FN-7637 Fusion-Task-Lineage: 953c5b82-a079-4600-b3af-45c974cd5014 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -775,6 +775,17 @@ Bundled plugins shipped in `@runfusion/fusion` are tracked by the staged bundled
|
||||
|
||||
This catches stale `dist/` drift: `resolvePluginEntryPath` prefers `bundled.js` and compiled `dist/index.js` before falling back to `src/index.ts`, while per-plugin `dist/` is gitignored and can lag behind source edits. If `bundled-plugin-freshness` reports `dist is stale relative to src`, run `pnpm build` from the workspace root to regenerate plugin `dist/` outputs and staged CLI plugin artifacts before rerunning tests.
|
||||
|
||||
### Bundled-plugin auto-install: CLI and desktop
|
||||
|
||||
<!-- FNXC:PluginLoader 2026-07-07-00:00: FN-7637 ported bundled-plugin auto-install into a shared, host-agnostic @fusion/core helper so the desktop embedded runtime auto-installs bundled runtime plugins the same way the CLI dashboard/serve/daemon commands do. Documented here so plugin authors and host maintainers know both hosts share one code path and only differ in bundle-directory resolution. -->
|
||||
|
||||
The install/update/fail-soft-load logic for `BUNDLED_PLUGIN_IDS` (Dependency Graph, Hermes, OpenClaw, Paperclip, Cursor, CLI Printing Press, Compound Engineering, Linear Import, Reports, WhatsApp Chat, Roadmap) lives once in `packages/core/src/plugins/bundled-plugin-install.ts` as `ensureBundledPluginInstalled(pluginStore, pluginLoader, pluginId, getCandidatePluginDirs)`. The only host-specific input is `getCandidatePluginDirs` — the ordered list of directories to probe for a plugin's `manifest.json` — because the CLI and desktop stage bundled plugins differently:
|
||||
|
||||
- **CLI** (`packages/cli/src/plugins/bundled-plugin-install.ts`): resolves `<cli>/dist/plugins/<manifest-id>/` (plus source/dev fallbacks) from its own `import.meta.url`. The CLI module is now a thin adapter that supplies this resolver and re-exports the same public surface (`ensureBundledPluginInstalled`, `ensureBundledDependencyGraphPluginInstalled`, `ensureBundledCursorRuntimePluginInstalled`, `isBundledPluginId`, `BUNDLED_PLUGIN_IDS`, `resolvePluginEntryPath`) that `dashboard.ts`, `serve.ts`, and `daemon.ts` already depend on — no behavior change for CLI hosts.
|
||||
- **Desktop** (`packages/desktop/src/bundled-plugin-dirs.ts`): resolves each manifest id (`fusion-plugin-<short-name>`) to its staged `@fusion-plugin-examples/<short-name>` npm package directory via `import.meta.resolve` against the package's `"."` export, which works whether `node_modules` is flat/hoisted (the packaged `pnpm deploy` closure) or nested (workspace dev). These plugins are workspace dependencies of `@fusion/dashboard`, so `packages/desktop/scripts/workspace-tools.ts#stageDesktopDeploy` already materializes their `manifest.json` + `dist/index.js` into the desktop closure — no desktop-specific build/staging changes were needed. A bundled id desktop does not depend on (currently `reports`, `whatsapp-chat`, `linear-import`) resolves to no candidate directories and is correctly reported as `missing-bundle`, matching the CLI's "not found in this build" behavior for an unstaged plugin.
|
||||
|
||||
Both `packages/desktop/src/local-runtime.ts` (`createDashboardServerDefault`) and `packages/desktop/src/local-server.ts` (`DesktopLocalServerManager.start`) wire this identically to the CLI `dashboard` command: auto-install the bundled Dependency Graph plugin before `loadAllPlugins()`, and pass an `ensureBundledPluginInstalled` callback into `createServer(...)` options so `PUT /api/plugins/:id/settings` can lazy-install Hermes/OpenClaw/Paperclip/etc. on first Settings save. Both paths run this inside the existing fail-soft try/catch (see the desktop `pluginStore`/`pluginLoader` wiring, FN-7623) so a plugin auto-install failure logs (via the `FUSION_STARTUP_TRACE` `strace` helper in `local-runtime.ts`) but never crashes embedded startup. `packages/desktop` still does not depend on the CLI package (`@runfusion/fusion`) — the shared helper lives entirely in `@fusion/core`.
|
||||
|
||||
Runtime host context contract:
|
||||
- Registered views receive a `context` object from the dashboard host (`PluginDashboardViewContext`).
|
||||
- Context includes the active `projectId`, current visible `tasks`, optional `workflowSteps`, `openTaskDetail` for launching the native task detail flow, and `openFile(path, options?)` for opening project-relative files in the dashboard's built-in file viewer.
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
/**
|
||||
* FNXC:PluginLoader 2026-07-07-00:00:
|
||||
* bundled-plugin-install.ts is now a thin CLI adapter that delegates the pure
|
||||
* install/update/fail-soft-load logic to @fusion/core's host-agnostic shared
|
||||
* helper (packages/core/src/plugins/bundled-plugin-install.ts) — see that
|
||||
* package's own test suite for full EnsureBundledResult coverage (installed /
|
||||
* updated / already-installed / missing-bundle) and resolvePluginEntryPath
|
||||
* coverage (packages/core/src/__tests__/plugin-loader.test.ts). This file only
|
||||
* asserts the CLI-specific concern: candidate bundle-directory resolution from
|
||||
* `import.meta.url`, i.e. the `<cli>/dist/plugins/<id>` staged-runtime layout
|
||||
* and its dev/source fallbacks (FN-7637).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────
|
||||
// vi.mock factories are hoisted, so we use vi.hoisted() for mock references.
|
||||
|
||||
const { mockExistsSync, mockReaddirSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } =
|
||||
vi.hoisted(() => ({
|
||||
mockExistsSync: vi.fn<(path: string) => boolean>(),
|
||||
@@ -28,183 +37,59 @@ vi.mock("node:fs/promises", () => ({
|
||||
copyFile: mockCopyFile,
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
validatePluginManifest: mockValidatePluginManifest,
|
||||
}));
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@fusion/core")>();
|
||||
return { ...actual, validatePluginManifest: mockValidatePluginManifest };
|
||||
});
|
||||
|
||||
// Import SUT after mocks are in place
|
||||
import {
|
||||
BUNDLED_PLUGIN_IDS,
|
||||
ensureBundledDependencyGraphPluginInstalled,
|
||||
ensureBundledCursorRuntimePluginInstalled,
|
||||
ensureBundledPluginInstalled,
|
||||
resolvePluginEntryPath,
|
||||
} from "../bundled-plugin-install.js";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
||||
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
|
||||
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
||||
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
|
||||
const REPORTS_PLUGIN_ID = "fusion-plugin-reports";
|
||||
const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press";
|
||||
const COMPOUND_ENGINEERING_PLUGIN_ID = "fusion-plugin-compound-engineering";
|
||||
const LINEAR_IMPORT_PLUGIN_ID = "fusion-plugin-linear-import";
|
||||
|
||||
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
|
||||
return {
|
||||
id: BUNDLED_PLUGIN_ID,
|
||||
id: "fusion-plugin-dependency-graph",
|
||||
name: "Dependency Graph",
|
||||
version: "0.1.0",
|
||||
description: "Top-level dependency graph dashboard view",
|
||||
dashboardViews: [
|
||||
{
|
||||
viewId: "graph",
|
||||
label: "Graph",
|
||||
componentPath: "./dashboard-view",
|
||||
icon: "Network",
|
||||
placement: "more",
|
||||
order: 40,
|
||||
},
|
||||
{ viewId: "graph", label: "Graph", componentPath: "./dashboard-view", icon: "Network", placement: "more", order: 40 },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface PluginLike {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
state: string;
|
||||
settings: Record<string, unknown>;
|
||||
dependencies?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function makePlugin(overrides?: Partial<PluginLike>): PluginLike {
|
||||
return {
|
||||
id: BUNDLED_PLUGIN_ID,
|
||||
name: "Dependency Graph",
|
||||
version: "0.1.0",
|
||||
description: "Top-level dependency graph dashboard view",
|
||||
path: "", // callers should set this
|
||||
enabled: true,
|
||||
state: "installed",
|
||||
settings: {},
|
||||
dependencies: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePluginStore() {
|
||||
const plugins = new Map<string, PluginLike>();
|
||||
const plugins = new Map<string, { path: string; version: string; enabled: boolean; id: string }>();
|
||||
return {
|
||||
getPlugin: vi.fn(async (id: string) => {
|
||||
const plugin = plugins.get(id);
|
||||
if (!plugin)
|
||||
throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
|
||||
if (!plugin) throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
|
||||
return { ...plugin };
|
||||
}),
|
||||
registerPlugin: vi.fn(async (input: { manifest: unknown; path: string }) => {
|
||||
const manifest = input.manifest as ReturnType<typeof makeManifest>;
|
||||
const plugin = makePlugin({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
path: input.path,
|
||||
});
|
||||
plugins.set(manifest.id, plugin);
|
||||
const plugin = { id: manifest.id ?? "", version: manifest.version ?? "0.0.0", path: input.path, enabled: true };
|
||||
plugins.set(plugin.id, plugin);
|
||||
return plugin;
|
||||
}),
|
||||
updatePlugin: vi.fn(async (id: string, updates: Record<string, unknown>) => {
|
||||
const plugin = plugins.get(id);
|
||||
if (!plugin) throw new Error(`Plugin "${id}" not found`);
|
||||
const updated = { ...plugin, ...updates, updatedAt: new Date().toISOString() };
|
||||
const updated = { ...plugin, ...updates };
|
||||
plugins.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
/** Directly inject a plugin record for test setup */
|
||||
_inject(plugin: PluginLike) {
|
||||
plugins.set(plugin.id, { ...plugin });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makePluginLoader() {
|
||||
return {
|
||||
loadPlugin: vi.fn(async () => {}),
|
||||
unloadPlugin: vi.fn(async () => {}),
|
||||
getLoadedPlugins: vi.fn(() => new Map()),
|
||||
isPluginLoaded: vi.fn(() => false),
|
||||
};
|
||||
return { loadPlugin: vi.fn(async () => {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup: bundled manifest exists at the first candidate path and is valid.
|
||||
* The resolver's first candidate includes "dist/plugins/..." when running from source.
|
||||
*/
|
||||
function setupBundleExists(manifestOverrides?: Partial<{ id: string; version: string }>) {
|
||||
const manifest = makeManifest(manifestOverrides);
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (typeof p !== "string") return false;
|
||||
if (p.endsWith("manifest.json") && p.includes("dist")) return true;
|
||||
if (p.includes("dist") && (p.endsWith("/bundled.js") || p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/** Setup: no bundled manifest found on any candidate path. */
|
||||
function setupBundleMissing() {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
}
|
||||
|
||||
/** Setup: bundled manifest found but invalid. */
|
||||
function setupBundleInvalid() {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (typeof p === "string" && p.endsWith("manifest.json") && p.includes("dist")) return true;
|
||||
return false;
|
||||
});
|
||||
const badManifest = { id: "bad" };
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(badManifest));
|
||||
mockValidatePluginManifest.mockReturnValue({
|
||||
valid: false,
|
||||
errors: ["Missing required field: name"],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the resolver to determine the actual resolved bundled path.
|
||||
* Registers the plugin and captures the path from the registerPlugin call.
|
||||
*/
|
||||
async function getResolvedBundledPath(): Promise<string> {
|
||||
setupBundleExists();
|
||||
const probeStore = makePluginStore();
|
||||
const probeLoader = makePluginLoader();
|
||||
await ensureBundledDependencyGraphPluginInstalled(
|
||||
probeStore as unknown as import("@fusion/core").PluginStore,
|
||||
probeLoader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
const call = probeStore.registerPlugin.mock.calls[0];
|
||||
const path = (call?.[0] as { path: string })?.path ?? "";
|
||||
expect(path.endsWith(".js") || path.endsWith(".ts")).toBe(true);
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockReaddirSync.mockReturnValue([{ name: "index.ts", isDirectory: () => false }]);
|
||||
@@ -213,54 +98,16 @@ beforeEach(() => {
|
||||
mockCopyFile.mockResolvedValue();
|
||||
});
|
||||
|
||||
describe("resolvePluginEntryPath", () => {
|
||||
it("prefers bundled.js when both bundled and source entries exist", () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/bundled.js"));
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
|
||||
});
|
||||
|
||||
it("prefers bundled.js when source entry is unavailable", () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("/bundled.js"));
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
|
||||
});
|
||||
|
||||
it("prefers src/index.ts when bundled.js is unavailable and src is newer than dist", () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
|
||||
mockStatSync.mockImplementation((p: string) => ({
|
||||
isDirectory: () => false,
|
||||
mtimeMs: p.endsWith("/dist/index.js") ? 1 : 2,
|
||||
}));
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
|
||||
});
|
||||
|
||||
it("prefers dist/index.js when bundled.js is unavailable and dist is newer", () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
|
||||
mockStatSync.mockImplementation((p: string) => ({
|
||||
isDirectory: () => false,
|
||||
mtimeMs: p.endsWith("/dist/index.js") ? 2 : 1,
|
||||
}));
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
|
||||
});
|
||||
|
||||
it("prefers dist/index.js when bundled.js is unavailable and mtimes are equal", () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
|
||||
mockStatSync.mockImplementation(() => ({ isDirectory: () => false, mtimeMs: 1 }));
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
|
||||
});
|
||||
|
||||
it("falls back to src/index.ts for workspace-dev plugins without build outputs", () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts"));
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
|
||||
});
|
||||
|
||||
it("returns null when no loadable entry file exists", () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
expect(resolvePluginEntryPath("/tmp/plugin")).toBeNull();
|
||||
describe("bundled plugin id set", () => {
|
||||
it("re-exports the full BUNDLED_PLUGIN_IDS set from @fusion/core", () => {
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain("fusion-plugin-dependency-graph");
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain("fusion-plugin-hermes-runtime");
|
||||
expect(BUNDLED_PLUGIN_IDS.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
it("installs paperclip runtime from bundled dist/plugins layout (global install regression)", async () => {
|
||||
describe("CLI candidate bundle-directory resolution", () => {
|
||||
it("installs from the bundled/global runtime layout (<cli>/dist/plugins/<id>/bundled.js — global install regression)", async () => {
|
||||
const PAPERCLIP_PLUGIN_ID = "fusion-plugin-paperclip-runtime";
|
||||
const globalDistPluginRoot = `/opt/homebrew/lib/node_modules/@runfusion/fusion/dist/plugins/${PAPERCLIP_PLUGIN_ID}`;
|
||||
|
||||
@@ -286,26 +133,23 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
stat: mockFsStat,
|
||||
copyFile: mockCopyFile,
|
||||
}));
|
||||
vi.doMock("@fusion/core", () => ({
|
||||
validatePluginManifest: mockValidatePluginManifest,
|
||||
}));
|
||||
vi.doMock("@fusion/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@fusion/core")>();
|
||||
return { ...actual, validatePluginManifest: mockValidatePluginManifest };
|
||||
});
|
||||
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
const { ensureBundledPluginInstalled: ensureFromBundledBuild } = await import("../bundled-plugin-install.js");
|
||||
|
||||
const result = await ensureFromBundledBuild(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
PAPERCLIP_PLUGIN_ID,
|
||||
);
|
||||
const result = await ensureFromBundledBuild(store as never, loader as never, PAPERCLIP_PLUGIN_ID);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
|
||||
expect(registerCall.path.endsWith(`/fusion-plugin-paperclip-runtime/bundled.js`)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to dev dist/plugins candidate when bundled-runtime candidate is absent", async () => {
|
||||
it("falls back to the dev dist/plugins candidate when the bundled-runtime candidate is absent", async () => {
|
||||
const PAPERCLIP_PLUGIN_ID = "fusion-plugin-paperclip-runtime";
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (typeof p !== "string") return false;
|
||||
@@ -321,412 +165,59 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
PAPERCLIP_PLUGIN_ID,
|
||||
);
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, PAPERCLIP_PLUGIN_ID);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
|
||||
expect(registerCall.path).toContain(`/dist/plugins/${PAPERCLIP_PLUGIN_ID}/src/index.ts`);
|
||||
});
|
||||
|
||||
it("includes roadmap plugin in bundled plugin ids", () => {
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(ROADMAP_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("includes CLI printing press plugin in bundled plugin ids", () => {
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(CLI_PRINTING_PRESS_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("includes reports plugin in bundled plugin ids", () => {
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("includes compound engineering plugin in bundled plugin ids", () => {
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(COMPOUND_ENGINEERING_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("includes Linear import plugin in bundled plugin ids", () => {
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(LINEAR_IMPORT_PLUGIN_ID);
|
||||
});
|
||||
it("fresh install: registers and loads the plugin when not in DB", async () => {
|
||||
setupBundleExists();
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledOnce();
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: BUNDLED_PLUGIN_ID }),
|
||||
}),
|
||||
);
|
||||
// Fresh install → enabled by default → should be loaded
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("already installed with matching path/version → returns already-installed without DB writes", async () => {
|
||||
// First probe to get the actual resolved path
|
||||
const bundledPath = await getResolvedBundledPath();
|
||||
|
||||
vi.clearAllMocks();
|
||||
const manifest = setupBundleExists();
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
// Inject a plugin that matches the current bundle path and version
|
||||
store._inject(makePlugin({ path: bundledPath, version: manifest.version }));
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("already-installed");
|
||||
expect(store.updatePlugin).not.toHaveBeenCalled();
|
||||
expect(store.registerPlugin).not.toHaveBeenCalled();
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("already installed with stale path → updates path to current bundled path", async () => {
|
||||
const bundledPath = await getResolvedBundledPath();
|
||||
const OLD_PATH = "/old/cli/dist/plugins/fusion-plugin-dependency-graph/bundled.js";
|
||||
|
||||
vi.clearAllMocks();
|
||||
const manifest = setupBundleExists();
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
// Plugin registered with the OLD path, but current version
|
||||
store._inject(makePlugin({ path: OLD_PATH, version: manifest.version }));
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalledWith(
|
||||
BUNDLED_PLUGIN_ID,
|
||||
expect.objectContaining({ path: bundledPath }),
|
||||
);
|
||||
// Plugin was enabled → should be loaded
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("already installed with stale version → updates version to current manifest version", async () => {
|
||||
const bundledPath = await getResolvedBundledPath();
|
||||
|
||||
vi.clearAllMocks();
|
||||
const manifest = setupBundleExists({ version: "0.2.0" });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
// Plugin registered with old version but same path
|
||||
store._inject(makePlugin({ path: bundledPath, version: "0.1.0" }));
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalledWith(
|
||||
BUNDLED_PLUGIN_ID,
|
||||
expect.objectContaining({ version: "0.2.0" }),
|
||||
);
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("disabled plugin → path/version updated but plugin NOT loaded (user choice respected)", async () => {
|
||||
setupBundleExists({ version: "0.2.0" });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
// Plugin explicitly disabled by user with stale version
|
||||
// Use a path that definitely won't match the resolved path
|
||||
store._inject(makePlugin({ path: "/stale/path/plugin", version: "0.1.0", enabled: false }));
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalled();
|
||||
// User disabled the plugin → should NOT be loaded
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("migrates an existing directory-backed install to the resolved entry file", async () => {
|
||||
const bundledPath = await getResolvedBundledPath();
|
||||
const staleDirectoryPath = "/old/cli/dist/plugins/fusion-plugin-dependency-graph";
|
||||
|
||||
vi.clearAllMocks();
|
||||
setupBundleExists();
|
||||
mockStatSync.mockImplementation((path: string) => ({
|
||||
isDirectory: () => path === staleDirectoryPath,
|
||||
}));
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
store._inject(makePlugin({ path: staleDirectoryPath }));
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalledWith(
|
||||
BUNDLED_PLUGIN_ID,
|
||||
expect.objectContaining({ path: bundledPath }),
|
||||
);
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("returns missing-bundle when manifest exists but no loadable entry file exists", async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => typeof p === "string" && p.endsWith("manifest.json") && p.includes("dist"));
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest()));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("missing-bundle");
|
||||
expect(store.registerPlugin).not.toHaveBeenCalled();
|
||||
expect(store.updatePlugin).not.toHaveBeenCalled();
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("missing bundle (no bundled manifest found) → returns missing-bundle without error", async () => {
|
||||
setupBundleMissing();
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("missing-bundle");
|
||||
expect(store.registerPlugin).not.toHaveBeenCalled();
|
||||
expect(store.updatePlugin).not.toHaveBeenCalled();
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalid bundled manifest → throws descriptive error", async () => {
|
||||
setupBundleInvalid();
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
await expect(
|
||||
ensureBundledDependencyGraphPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
),
|
||||
).rejects.toThrow("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("registers Cursor runtime through the dedicated helper", async () => {
|
||||
const manifest = makeManifest({ id: CURSOR_PLUGIN_ID, name: "Cursor Runtime" });
|
||||
it("dedicated Cursor runtime helper resolves through the same CLI candidate dirs", async () => {
|
||||
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith("manifest.json") && p.includes(CURSOR_PLUGIN_ID)) return true;
|
||||
if (p.endsWith("/src/index.ts") && p.includes(CURSOR_PLUGIN_ID)) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest({ id: CURSOR_PLUGIN_ID, name: "Cursor Runtime" })));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledCursorRuntimePluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ manifest: expect.objectContaining({ id: CURSOR_PLUGIN_ID }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers Linear import plugin via generic bundled installer", async () => {
|
||||
const manifest = makeManifest({ id: LINEAR_IMPORT_PLUGIN_ID, name: "Linear Import" });
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith("manifest.json") && p.includes(LINEAR_IMPORT_PLUGIN_ID)) return true;
|
||||
if (p.endsWith("/bundled.js") && p.includes(LINEAR_IMPORT_PLUGIN_ID)) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
const result = await ensureBundledPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
LINEAR_IMPORT_PLUGIN_ID,
|
||||
);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ manifest: expect.objectContaining({ id: LINEAR_IMPORT_PLUGIN_ID }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers roadmap plugin via generic bundled installer", async () => {
|
||||
const manifest = makeManifest({ id: ROADMAP_PLUGIN_ID, name: "Roadmaps" });
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith("manifest.json") && p.includes(ROADMAP_PLUGIN_ID)) return true;
|
||||
if (p.endsWith("/src/index.ts") && p.includes(ROADMAP_PLUGIN_ID)) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
ROADMAP_PLUGIN_ID,
|
||||
);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ manifest: expect.objectContaining({ id: ROADMAP_PLUGIN_ID }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers reports plugin via generic bundled installer", async () => {
|
||||
const manifest = makeManifest({ id: REPORTS_PLUGIN_ID, name: "Reports" });
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith("manifest.json") && p.includes(REPORTS_PLUGIN_ID)) return true;
|
||||
if (p.endsWith("/src/index.ts") && p.includes(REPORTS_PLUGIN_ID)) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
REPORTS_PLUGIN_ID,
|
||||
);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ manifest: expect.objectContaining({ id: REPORTS_PLUGIN_ID }) }),
|
||||
);
|
||||
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
|
||||
expect(registerCall.path).toContain(REPORTS_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("registers Hermes from bundled.js when bundled, src, and dist entries all exist", async () => {
|
||||
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith("manifest.json") && p.includes(HERMES_PLUGIN_ID)) return true;
|
||||
if (p.endsWith("/bundled.js") && p.includes(HERMES_PLUGIN_ID)) return true;
|
||||
if (p.endsWith("/src/index.ts") && p.includes(HERMES_PLUGIN_ID)) return true;
|
||||
if (p.endsWith("/dist/index.js") && p.includes(HERMES_PLUGIN_ID)) return true;
|
||||
return false;
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(
|
||||
store as unknown as import("@fusion/core").PluginStore,
|
||||
loader as unknown as import("@fusion/core").PluginLoader,
|
||||
HERMES_PLUGIN_ID,
|
||||
);
|
||||
const result = await ensureBundledCursorRuntimePluginInstalled(store as never, loader as never);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
|
||||
expect(registerCall.path).toContain(`${HERMES_PLUGIN_ID}/bundled.js`);
|
||||
expect(registerCall.path).toContain(CURSOR_PLUGIN_ID);
|
||||
});
|
||||
|
||||
// Heavy integration test: runs esbuild to bundle the real dependency-graph
|
||||
// plugin and load it through a live PluginLoader. ~18s wall on a fast laptop.
|
||||
// The other tests in this file cover the install/upgrade logic with mocks;
|
||||
// this one is gated behind FUSION_RUN_SLOW_TESTS=1 so day-to-day runs stay fast.
|
||||
it.skipIf(process.env.FUSION_RUN_SLOW_TESTS !== "1")("loads the real bundled dependency graph plugin and persists a started state", async () => {
|
||||
const { existsSync, mkdtempSync, statSync } = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
const { cp, mkdir, readFile, rm, stat, copyFile } = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const { buildSync } = await import("esbuild");
|
||||
const { PluginLoader } = await import("../../../../core/src/plugin-loader.ts");
|
||||
const { PluginStore } = await import("../../../../core/src/plugin-store.ts");
|
||||
|
||||
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
|
||||
const sourceRoot = fileURLToPath(new URL("../../../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
|
||||
const stagedRoot = fileURLToPath(new URL("../../../plugins/fusion-plugin-dependency-graph", import.meta.url));
|
||||
const pluginStateRoot = mkdtempSync(join(tmpdir(), "fn4128-bundled-plugin-"));
|
||||
|
||||
await rm(stagedRoot, { recursive: true, force: true });
|
||||
await mkdir(stagedRoot, { recursive: true });
|
||||
await cp(join(sourceRoot, "manifest.json"), join(stagedRoot, "manifest.json"));
|
||||
|
||||
buildSync({
|
||||
entryPoints: [join(sourceRoot, "src", "index.ts")],
|
||||
outfile: join(stagedRoot, "bundled.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
alias: {
|
||||
"@fusion/plugin-sdk": join(repoRoot, "packages", "plugin-sdk", "src", "index.ts"),
|
||||
},
|
||||
logLevel: "silent",
|
||||
it("deprecated Dependency Graph helper resolves through the same CLI candidate dirs", async () => {
|
||||
const DEP_GRAPH_ID = "fusion-plugin-dependency-graph";
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p.endsWith("manifest.json") && p.includes(DEP_GRAPH_ID)) return true;
|
||||
if (p.endsWith("/src/index.ts") && p.includes(DEP_GRAPH_ID)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
mockExistsSync.mockImplementation((path: string) => existsSync(path));
|
||||
mockStatSync.mockImplementation((path: string) => statSync(path));
|
||||
mockReadFile.mockImplementation((path: string, encoding: string) => readFile(path, encoding as BufferEncoding));
|
||||
mockFsStat.mockImplementation((path: string) => stat(path));
|
||||
mockCopyFile.mockImplementation((src: string, dest: string) => copyFile(src, dest));
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest()));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
|
||||
try {
|
||||
const pluginStore = new PluginStore(pluginStateRoot, { inMemoryDb: true, centralGlobalDir: pluginStateRoot });
|
||||
await pluginStore.init();
|
||||
const taskStore = {
|
||||
getRootDir: () => repoRoot,
|
||||
logActivity: vi.fn(),
|
||||
getPluginStore: () => pluginStore,
|
||||
} as any;
|
||||
const loader = new PluginLoader({ pluginStore, taskStore });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(pluginStore, loader);
|
||||
const storedPlugin = await pluginStore.getPlugin(BUNDLED_PLUGIN_ID);
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(store as never, loader as never);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(storedPlugin.path.endsWith("/fusion-plugin-dependency-graph/bundled.js")).toBe(true);
|
||||
expect(storedPlugin.state).toBe("started");
|
||||
expect(storedPlugin.error ?? null).toBeNull();
|
||||
} finally {
|
||||
await rm(stagedRoot, { recursive: true, force: true });
|
||||
await rm(pluginStateRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
expect(result).toBe("installed");
|
||||
});
|
||||
|
||||
it("returns missing-bundle when no CLI candidate dir has a manifest", async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, "fusion-plugin-roadmap");
|
||||
|
||||
expect(result).toBe("missing-bundle");
|
||||
expect(store.registerPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Drift guard for the intentionally duplicated resolvePluginEntryPath.
|
||||
*
|
||||
* The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks
|
||||
* work in tests) while @fusion/core owns the copy used by the dashboard
|
||||
* install/enable routes. This test runs both against real on-disk layouts and
|
||||
* asserts identical results, so a candidate-list change applied to one copy
|
||||
* but not the other fails CI instead of silently diverging.
|
||||
*
|
||||
* No fs mocks here on purpose — vitest module mocks don't reach the
|
||||
* externalized @fusion/core import, so real temp directories are the only
|
||||
* seam that exercises both implementations equally.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js";
|
||||
import { resolvePluginEntryPath as coreResolve } from "@fusion/core";
|
||||
|
||||
describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "entry-path-sync-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function touch(relative: string) {
|
||||
const full = join(dir, relative);
|
||||
mkdirSync(dirname(full), { recursive: true });
|
||||
writeFileSync(full, "// entry\n");
|
||||
}
|
||||
|
||||
const older = new Date("2026-01-01T00:00:00.000Z");
|
||||
const newer = new Date("2026-01-01T00:01:00.000Z");
|
||||
|
||||
const layouts: Array<{
|
||||
name: string;
|
||||
files: string[];
|
||||
expected: string | null;
|
||||
mtimes?: Record<string, Date>;
|
||||
}> = [
|
||||
{ name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" },
|
||||
{ name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" },
|
||||
{ name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" },
|
||||
{ name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" },
|
||||
{
|
||||
name: "dist + src, src newer → src/index.ts",
|
||||
files: ["dist/index.js", "src/index.ts"],
|
||||
expected: "src/index.ts",
|
||||
mtimes: { "dist/index.js": older, "src/index.ts": newer },
|
||||
},
|
||||
{
|
||||
name: "dist + src, dist newer → dist/index.js",
|
||||
files: ["dist/index.js", "src/index.ts"],
|
||||
expected: "dist/index.js",
|
||||
mtimes: { "dist/index.js": newer, "src/index.ts": older },
|
||||
},
|
||||
{
|
||||
name: "dist + src, equal mtimes → dist/index.js",
|
||||
files: ["dist/index.js", "src/index.ts"],
|
||||
expected: "dist/index.js",
|
||||
mtimes: { "dist/index.js": older, "src/index.ts": older },
|
||||
},
|
||||
{
|
||||
name: "dist + src, non-index src file newer → src/index.ts",
|
||||
files: ["dist/index.js", "src/index.ts", "src/settings.ts"],
|
||||
expected: "src/index.ts",
|
||||
mtimes: { "dist/index.js": older, "src/index.ts": older, "src/settings.ts": newer },
|
||||
},
|
||||
{
|
||||
name: "bundled.js + dist + src, src newer → bundled.js",
|
||||
files: ["bundled.js", "dist/index.js", "src/index.ts"],
|
||||
expected: "bundled.js",
|
||||
mtimes: { "dist/index.js": older, "src/index.ts": newer },
|
||||
},
|
||||
{ name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" },
|
||||
{ name: "no entry files", files: ["README.md"], expected: null },
|
||||
];
|
||||
|
||||
for (const layout of layouts) {
|
||||
it(`resolves identically for: ${layout.name}`, () => {
|
||||
for (const f of layout.files) touch(f);
|
||||
for (const [file, mtime] of Object.entries(layout.mtimes ?? {})) {
|
||||
utimesSync(join(dir, file), mtime, mtime);
|
||||
}
|
||||
const expected = layout.expected === null ? null : join(dir, layout.expected);
|
||||
|
||||
expect(cliResolve(dir)).toBe(expected);
|
||||
expect(coreResolve(dir)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,37 +1,30 @@
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
/**
|
||||
* FNXC:PluginLoader 2026-07-07-00:00:
|
||||
* Bundled-plugin auto-install logic was ported to @fusion/core
|
||||
* (packages/core/src/plugins/bundled-plugin-install.ts) so the desktop embedded
|
||||
* runtime can auto-install bundled runtime plugins without depending on this CLI
|
||||
* package (FN-7637). This module is now a thin, behavior-preserving CLI adapter:
|
||||
* it supplies the CLI-specific candidate bundle-directory resolution
|
||||
* (`<cli>/dist/plugins/<id>` staged layout, resolved from `import.meta.url`) to
|
||||
* the shared helper and re-exports the same public surface `dashboard.ts`,
|
||||
* `serve.ts`, and `daemon.ts` already depend on. `resolvePluginEntryPath` is
|
||||
* re-exported directly from `@fusion/core` (no local duplicate) since the
|
||||
* shared helper already delegates to it.
|
||||
*/
|
||||
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { validatePluginManifest, type PluginInstallation, type PluginLoader, type PluginManifest, type PluginStore } from "@fusion/core";
|
||||
import {
|
||||
ensureBundledCursorRuntimePluginInstalled as coreEnsureBundledCursorRuntimePluginInstalled,
|
||||
ensureBundledDependencyGraphPluginInstalled as coreEnsureBundledDependencyGraphPluginInstalled,
|
||||
ensureBundledPluginInstalled as coreEnsureBundledPluginInstalled,
|
||||
type EnsureBundledResult,
|
||||
type PluginLoader,
|
||||
type PluginStore,
|
||||
} from "@fusion/core";
|
||||
|
||||
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
||||
const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
||||
|
||||
export const BUNDLED_PLUGIN_IDS = [
|
||||
"fusion-plugin-dependency-graph",
|
||||
"fusion-plugin-reports",
|
||||
"fusion-plugin-whatsapp-chat",
|
||||
"fusion-plugin-roadmap",
|
||||
"fusion-plugin-hermes-runtime",
|
||||
"fusion-plugin-openclaw-runtime",
|
||||
"fusion-plugin-paperclip-runtime",
|
||||
"fusion-plugin-cursor-runtime",
|
||||
"fusion-plugin-cli-printing-press",
|
||||
"fusion-plugin-compound-engineering",
|
||||
"fusion-plugin-linear-import",
|
||||
] as const;
|
||||
|
||||
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
|
||||
|
||||
export function isBundledPluginId(id: string): id is BundledPluginId {
|
||||
return (BUNDLED_PLUGIN_IDS as readonly string[]).includes(id);
|
||||
}
|
||||
|
||||
export type EnsureBundledResult =
|
||||
| "installed"
|
||||
| "updated"
|
||||
| "already-installed"
|
||||
| "missing-bundle";
|
||||
export { BUNDLED_PLUGIN_IDS, isBundledPluginId, resolvePluginEntryPath } from "@fusion/core";
|
||||
export type { BundledPluginId, EnsureBundledResult } from "@fusion/core";
|
||||
|
||||
function getCandidatePluginDirs(pluginId: string): string[] {
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -48,199 +41,12 @@ function getCandidatePluginDirs(pluginId: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
async function loadManifest(pluginDir: string): Promise<PluginManifest> {
|
||||
const manifestPath = join(pluginDir, "manifest.json");
|
||||
const content = await readFile(manifestPath, "utf-8");
|
||||
const manifest = JSON.parse(content);
|
||||
const validation = validatePluginManifest(manifest);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function resolveBundledPluginDir(pluginId: string): string | null {
|
||||
for (const path of getCandidatePluginDirs(pluginId)) {
|
||||
if (existsSync(join(path, "manifest.json"))) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
|
||||
* does not allow directory imports, so we must register the explicit file the
|
||||
* loader will dynamic-import. Resolution keeps ./bundled.js unconditional
|
||||
* because production npm tarballs ship that esbuild-bundled entry. In
|
||||
* dev/worktree contexts where no bundle exists, ./dist/index.js remains the
|
||||
* prebuilt fallback unless any file under ./src/ is newer than dist/index.js;
|
||||
* then ./src/index.ts wins so stale gitignored dist output cannot mask a source
|
||||
* fix (FN-6615/FN-6596).
|
||||
*
|
||||
* FNXC:PluginLoader 2026-06-17-19:20:
|
||||
* Prefer fresher src over stale dist only when bundled.js is absent. This keeps
|
||||
* production tarballs on their bundled entry while preventing dev/worktree runs
|
||||
* from silently loading old gitignored build output after a source fix.
|
||||
*
|
||||
* Returns null when the directory exists but none of the loadable entry files
|
||||
* are present. Callers must treat that as a missing bundle rather than
|
||||
* persisting a directory path that Node cannot import.
|
||||
*
|
||||
* Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts),
|
||||
* which the dashboard install/enable routes use for the same contract.
|
||||
*/
|
||||
function newestSourceMtimeMs(srcDir: string): number | null {
|
||||
let newest = Number.NEGATIVE_INFINITY;
|
||||
|
||||
function visit(dir: string): boolean {
|
||||
const entries = (() => {
|
||||
try {
|
||||
return readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!entries) return false;
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(dir, entry.name);
|
||||
let entryStat: ReturnType<typeof statSync>;
|
||||
try {
|
||||
entryStat = statSync(entryPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entryStat.isDirectory()) {
|
||||
if (!visit(entryPath)) return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entryStat.mtimeMs > newest) {
|
||||
newest = entryStat.mtimeMs;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return visit(srcDir) && newest !== Number.NEGATIVE_INFINITY ? newest : null;
|
||||
}
|
||||
|
||||
function isSourceNewerThanDist(srcDir: string, distIndexPath: string): boolean {
|
||||
try {
|
||||
const distMtimeMs = statSync(distIndexPath).mtimeMs;
|
||||
const srcMtimeMs = newestSourceMtimeMs(srcDir);
|
||||
return srcMtimeMs !== null && srcMtimeMs > distMtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePluginEntryPath(pluginDir: string): string | null {
|
||||
const bundledPath = join(pluginDir, "bundled.js");
|
||||
if (existsSync(bundledPath)) {
|
||||
return bundledPath;
|
||||
}
|
||||
|
||||
const distIndexPath = join(pluginDir, "dist", "index.js");
|
||||
const srcDir = join(pluginDir, "src");
|
||||
const srcIndexPath = join(srcDir, "index.ts");
|
||||
const hasDist = existsSync(distIndexPath);
|
||||
const hasSrc = existsSync(srcIndexPath);
|
||||
|
||||
if (hasDist && hasSrc) {
|
||||
return isSourceNewerThanDist(srcDir, distIndexPath) ? srcIndexPath : distIndexPath;
|
||||
}
|
||||
if (hasDist) {
|
||||
return distIndexPath;
|
||||
}
|
||||
if (hasSrc) {
|
||||
return srcIndexPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isDirectoryPath(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBundledPluginInstalled(
|
||||
pluginStore: PluginStore,
|
||||
pluginLoader: PluginLoader,
|
||||
pluginId: string,
|
||||
): Promise<EnsureBundledResult> {
|
||||
let existingPlugin: PluginInstallation | null = null;
|
||||
try {
|
||||
existingPlugin = await pluginStore.getPlugin(pluginId);
|
||||
} catch {
|
||||
// Continue; plugin not installed yet.
|
||||
}
|
||||
|
||||
const bundledDir = resolveBundledPluginDir(pluginId);
|
||||
if (!bundledDir) {
|
||||
return "missing-bundle";
|
||||
}
|
||||
|
||||
const manifest = await loadManifest(bundledDir);
|
||||
const entryPath = resolvePluginEntryPath(bundledDir);
|
||||
|
||||
if (!entryPath) {
|
||||
console.warn(`[plugins] Bundled plugin "${pluginId}" is missing a loadable entry file in ${bundledDir}`);
|
||||
return "missing-bundle";
|
||||
}
|
||||
|
||||
if (existingPlugin) {
|
||||
const existingPathIsDirectory = isDirectoryPath(existingPlugin.path);
|
||||
const pathChanged = existingPathIsDirectory || existingPlugin.path !== entryPath;
|
||||
const versionChanged = existingPlugin.version !== manifest.version;
|
||||
|
||||
if (!pathChanged && !versionChanged) {
|
||||
if (existingPlugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
|
||||
}
|
||||
}
|
||||
return "already-installed";
|
||||
}
|
||||
|
||||
await pluginStore.updatePlugin(pluginId, {
|
||||
...(pathChanged ? { path: entryPath } : {}),
|
||||
...(versionChanged ? { version: manifest.version } : {}),
|
||||
});
|
||||
|
||||
if (existingPlugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
return "updated";
|
||||
}
|
||||
|
||||
const plugin = await pluginStore.registerPlugin({
|
||||
manifest,
|
||||
path: entryPath,
|
||||
});
|
||||
|
||||
if (plugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(plugin.id);
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", plugin.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
return "installed";
|
||||
return coreEnsureBundledPluginInstalled(pluginStore, pluginLoader, pluginId, getCandidatePluginDirs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,12 +57,12 @@ export async function ensureBundledDependencyGraphPluginInstalled(
|
||||
pluginStore: PluginStore,
|
||||
pluginLoader: PluginLoader,
|
||||
): Promise<EnsureBundledResult> {
|
||||
return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID);
|
||||
return coreEnsureBundledDependencyGraphPluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs);
|
||||
}
|
||||
|
||||
export async function ensureBundledCursorRuntimePluginInstalled(
|
||||
pluginStore: PluginStore,
|
||||
pluginLoader: PluginLoader,
|
||||
): Promise<EnsureBundledResult> {
|
||||
return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID);
|
||||
return coreEnsureBundledCursorRuntimePluginInstalled(pluginStore, pluginLoader, getCandidatePluginDirs);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────
|
||||
// vi.mock factories are hoisted, so we use vi.hoisted() for mock references.
|
||||
|
||||
const { mockExistsSync, mockReaddirSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } =
|
||||
vi.hoisted(() => ({
|
||||
mockExistsSync: vi.fn<(path: string) => boolean>(),
|
||||
mockReaddirSync: vi.fn<
|
||||
(path: string, options: { withFileTypes: true; encoding: "utf8" }) => Array<{ name: string; isDirectory: () => boolean }>
|
||||
>(),
|
||||
mockStatSync: vi.fn<(path: string) => { isDirectory: () => boolean; mtimeMs?: number }>(),
|
||||
mockReadFile: vi.fn<(path: string, encoding: string) => Promise<string>>(),
|
||||
mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(),
|
||||
mockCopyFile: vi.fn<(src: string, dest: string) => Promise<void>>(),
|
||||
mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: mockExistsSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
statSync: mockStatSync,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: mockReadFile,
|
||||
stat: mockFsStat,
|
||||
copyFile: mockCopyFile,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugin-types.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../plugin-types.js")>();
|
||||
return { ...actual, validatePluginManifest: mockValidatePluginManifest };
|
||||
});
|
||||
|
||||
// Import SUT after mocks are in place
|
||||
import {
|
||||
BUNDLED_PLUGIN_IDS,
|
||||
ensureBundledDependencyGraphPluginInstalled,
|
||||
ensureBundledCursorRuntimePluginInstalled,
|
||||
ensureBundledPluginInstalled,
|
||||
type BundledPluginDirResolver,
|
||||
} from "../bundled-plugin-install.js";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
||||
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
|
||||
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
||||
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
|
||||
const REPORTS_PLUGIN_ID = "fusion-plugin-reports";
|
||||
const LINEAR_IMPORT_PLUGIN_ID = "fusion-plugin-linear-import";
|
||||
|
||||
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
|
||||
return {
|
||||
id: BUNDLED_PLUGIN_ID,
|
||||
name: "Dependency Graph",
|
||||
version: "0.1.0",
|
||||
description: "Top-level dependency graph dashboard view",
|
||||
dashboardViews: [
|
||||
{
|
||||
viewId: "graph",
|
||||
label: "Graph",
|
||||
componentPath: "./dashboard-view",
|
||||
icon: "Network",
|
||||
placement: "more",
|
||||
order: 40,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface PluginLike {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
state: string;
|
||||
settings: Record<string, unknown>;
|
||||
dependencies?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function makePlugin(overrides?: Partial<PluginLike>): PluginLike {
|
||||
return {
|
||||
id: BUNDLED_PLUGIN_ID,
|
||||
name: "Dependency Graph",
|
||||
version: "0.1.0",
|
||||
description: "Top-level dependency graph dashboard view",
|
||||
path: "", // callers should set this
|
||||
enabled: true,
|
||||
state: "installed",
|
||||
settings: {},
|
||||
dependencies: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePluginStore() {
|
||||
const plugins = new Map<string, PluginLike>();
|
||||
return {
|
||||
getPlugin: vi.fn(async (id: string) => {
|
||||
const plugin = plugins.get(id);
|
||||
if (!plugin)
|
||||
throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
|
||||
return { ...plugin };
|
||||
}),
|
||||
registerPlugin: vi.fn(async (input: { manifest: unknown; path: string }) => {
|
||||
const manifest = input.manifest as ReturnType<typeof makeManifest>;
|
||||
const plugin = makePlugin({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
path: input.path,
|
||||
});
|
||||
plugins.set(manifest.id, plugin);
|
||||
return plugin;
|
||||
}),
|
||||
updatePlugin: vi.fn(async (id: string, updates: Record<string, unknown>) => {
|
||||
const plugin = plugins.get(id);
|
||||
if (!plugin) throw new Error(`Plugin "${id}" not found`);
|
||||
const updated = { ...plugin, ...updates, updatedAt: new Date().toISOString() };
|
||||
plugins.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
/** Directly inject a plugin record for test setup */
|
||||
_inject(plugin: PluginLike) {
|
||||
plugins.set(plugin.id, { ...plugin });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makePluginLoader() {
|
||||
return {
|
||||
loadPlugin: vi.fn(async () => {}),
|
||||
unloadPlugin: vi.fn(async () => {}),
|
||||
getLoadedPlugins: vi.fn(() => new Map()),
|
||||
isPluginLoaded: vi.fn(() => false),
|
||||
};
|
||||
}
|
||||
|
||||
/** A CLI-shaped resolver: single candidate dir per plugin id (mirrors <cli>/dist/plugins/<id>). */
|
||||
function cliShapedResolver(pluginId: string): string[] {
|
||||
return [`/cli/dist/plugins/${pluginId}`];
|
||||
}
|
||||
|
||||
/** A desktop-shaped resolver: single candidate dir per plugin id (mirrors node_modules/@fusion-plugin-examples/<short>). */
|
||||
function desktopShapedResolver(pluginId: string): string[] {
|
||||
const shortName = pluginId.replace(/^fusion-plugin-/, "");
|
||||
return [`/desktop/node_modules/@fusion-plugin-examples/${shortName}`];
|
||||
}
|
||||
|
||||
function setupBundleExists(resolver: BundledPluginDirResolver, manifestOverrides?: Partial<{ id: string; version: string }>) {
|
||||
const manifest = makeManifest(manifestOverrides);
|
||||
const [dir] = resolver(manifest.id ?? BUNDLED_PLUGIN_ID);
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (typeof p !== "string") return false;
|
||||
return p === `${dir}/manifest.json` || p === `${dir}/src/index.ts`;
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
return { manifest, dir };
|
||||
}
|
||||
|
||||
function setupBundleMissing() {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockReaddirSync.mockReturnValue([{ name: "index.ts", isDirectory: () => false }]);
|
||||
mockStatSync.mockImplementation(() => ({ isDirectory: () => false, mtimeMs: 0 }));
|
||||
mockFsStat.mockImplementation(async () => ({ isDirectory: () => false }));
|
||||
mockCopyFile.mockResolvedValue();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ensureBundledPluginInstalled (host-agnostic shared helper)", () => {
|
||||
it("includes the full bundled plugin id set", () => {
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(ROADMAP_PLUGIN_ID);
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(REPORTS_PLUGIN_ID);
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(LINEAR_IMPORT_PLUGIN_ID);
|
||||
expect(BUNDLED_PLUGIN_IDS).toContain(HERMES_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("fresh install: registers and loads the plugin when not in DB (CLI-shaped resolver)", async () => {
|
||||
const { dir } = setupBundleExists(cliShapedResolver);
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: `${dir}/src/index.ts`, manifest: expect.objectContaining({ id: BUNDLED_PLUGIN_ID }) }),
|
||||
);
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("fresh install: registers and loads the plugin when not in DB (desktop-shaped resolver)", async () => {
|
||||
const { dir } = setupBundleExists(desktopShapedResolver, { id: HERMES_PLUGIN_ID });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, HERMES_PLUGIN_ID, desktopShapedResolver);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: `${dir}/src/index.ts`, manifest: expect.objectContaining({ id: HERMES_PLUGIN_ID }) }),
|
||||
);
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(HERMES_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("already installed with matching path/version → returns already-installed without DB writes", async () => {
|
||||
const { manifest, dir } = setupBundleExists(cliShapedResolver);
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
store._inject(makePlugin({ path: `${dir}/src/index.ts`, version: manifest.version }));
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("already-installed");
|
||||
expect(store.updatePlugin).not.toHaveBeenCalled();
|
||||
expect(store.registerPlugin).not.toHaveBeenCalled();
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("already installed with stale path → updates path to current bundled path", async () => {
|
||||
const { manifest, dir } = setupBundleExists(cliShapedResolver);
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
store._inject(makePlugin({ path: "/old/path/bundled.js", version: manifest.version }));
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalledWith(
|
||||
BUNDLED_PLUGIN_ID,
|
||||
expect.objectContaining({ path: `${dir}/src/index.ts` }),
|
||||
);
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("already installed with stale version → updates version to current manifest version", async () => {
|
||||
const { dir } = setupBundleExists(cliShapedResolver, { version: "0.2.0" });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
store._inject(makePlugin({ path: `${dir}/src/index.ts`, version: "0.1.0" }));
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID, expect.objectContaining({ version: "0.2.0" }));
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("disabled plugin → path/version updated but plugin NOT loaded (user choice respected)", async () => {
|
||||
setupBundleExists(cliShapedResolver, { version: "0.2.0" });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
store._inject(makePlugin({ path: "/stale/path/plugin", version: "0.1.0", enabled: false }));
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalled();
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("migrates an existing directory-backed install to the resolved entry file", async () => {
|
||||
const { dir } = setupBundleExists(cliShapedResolver);
|
||||
const staleDirectoryPath = `${dir}`;
|
||||
mockStatSync.mockImplementation((path: string) => ({
|
||||
isDirectory: () => path === staleDirectoryPath,
|
||||
}));
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
store._inject(makePlugin({ path: staleDirectoryPath }));
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("updated");
|
||||
expect(store.updatePlugin).toHaveBeenCalledWith(
|
||||
BUNDLED_PLUGIN_ID,
|
||||
expect.objectContaining({ path: `${dir}/src/index.ts` }),
|
||||
);
|
||||
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
|
||||
});
|
||||
|
||||
it("returns missing-bundle when manifest exists but no loadable entry file exists", async () => {
|
||||
const dir = "/cli/dist/plugins/fusion-plugin-dependency-graph";
|
||||
mockExistsSync.mockImplementation((p: string) => typeof p === "string" && p === `${dir}/manifest.json`);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(makeManifest()));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("missing-bundle");
|
||||
expect(store.registerPlugin).not.toHaveBeenCalled();
|
||||
expect(store.updatePlugin).not.toHaveBeenCalled();
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("missing bundle (no bundled manifest found anywhere, e.g. desktop closure lacking the package) → returns missing-bundle without error", async () => {
|
||||
setupBundleMissing();
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, REPORTS_PLUGIN_ID, desktopShapedResolver);
|
||||
|
||||
expect(result).toBe("missing-bundle");
|
||||
expect(store.registerPlugin).not.toHaveBeenCalled();
|
||||
expect(store.updatePlugin).not.toHaveBeenCalled();
|
||||
expect(loader.loadPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalid bundled manifest → throws descriptive error", async () => {
|
||||
const dir = "/cli/dist/plugins/fusion-plugin-dependency-graph";
|
||||
mockExistsSync.mockImplementation((p: string) => typeof p === "string" && p === `${dir}/manifest.json`);
|
||||
mockReadFile.mockResolvedValue(JSON.stringify({ id: "bad" }));
|
||||
mockValidatePluginManifest.mockReturnValue({ valid: false, errors: ["Missing required field: name"] });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
await expect(
|
||||
ensureBundledPluginInstalled(store as never, loader as never, BUNDLED_PLUGIN_ID, cliShapedResolver),
|
||||
).rejects.toThrow("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("registers Cursor runtime through the dedicated helper", async () => {
|
||||
setupBundleExists(cliShapedResolver, { id: CURSOR_PLUGIN_ID });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledCursorRuntimePluginInstalled(store as never, loader as never, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ manifest: expect.objectContaining({ id: CURSOR_PLUGIN_ID }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers Dependency Graph through the deprecated dedicated helper", async () => {
|
||||
setupBundleExists(cliShapedResolver);
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledDependencyGraphPluginInstalled(store as never, loader as never, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ manifest: expect.objectContaining({ id: BUNDLED_PLUGIN_ID }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers Linear import plugin via generic bundled installer (desktop-shaped resolver)", async () => {
|
||||
setupBundleExists(desktopShapedResolver, { id: LINEAR_IMPORT_PLUGIN_ID });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, LINEAR_IMPORT_PLUGIN_ID, desktopShapedResolver);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ manifest: expect.objectContaining({ id: LINEAR_IMPORT_PLUGIN_ID }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers roadmap plugin via generic bundled installer", async () => {
|
||||
setupBundleExists(cliShapedResolver, { id: ROADMAP_PLUGIN_ID });
|
||||
const store = makePluginStore();
|
||||
const loader = makePluginLoader();
|
||||
|
||||
const result = await ensureBundledPluginInstalled(store as never, loader as never, ROADMAP_PLUGIN_ID, cliShapedResolver);
|
||||
|
||||
expect(result).toBe("installed");
|
||||
expect(store.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ manifest: expect.objectContaining({ id: ROADMAP_PLUGIN_ID }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
186
packages/core/src/plugins/bundled-plugin-install.ts
Normal file
186
packages/core/src/plugins/bundled-plugin-install.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* FNXC:PluginLoader 2026-07-07-00:00:
|
||||
* Bundled-plugin auto-install is host-agnostic in @fusion/core. Hosts (the CLI's
|
||||
* `<cli>/dist/plugins/<id>` staging layout vs the desktop `@fusion-plugin-examples/<short>`
|
||||
* node_modules layout) supply their own bundle-directory resolution — the ONLY
|
||||
* host-specific concern — via the `getCandidatePluginDirs` parameter below. This
|
||||
* lets the identical install/update/fail-soft-load logic run under both the CLI
|
||||
* `dashboard`/`serve`/`daemon` commands and the desktop embedded runtime
|
||||
* (`local-runtime.ts` / `local-server.ts`) without `packages/desktop` depending on
|
||||
* the CLI package (FN-7637; builds on FN-7623's desktop pluginStore/pluginLoader
|
||||
* wiring). Everything below except `getCandidatePluginDirs`/`resolveBundledPluginDir`
|
||||
* is a direct, behavior-preserving port of `packages/cli/src/plugins/bundled-plugin-install.ts`.
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { validatePluginManifest } from "../plugin-types.js";
|
||||
import type { PluginInstallation, PluginManifest } from "../plugin-types.js";
|
||||
import { resolvePluginEntryPath } from "../plugin-loader.js";
|
||||
import type { PluginLoader } from "../plugin-loader.js";
|
||||
import type { PluginStore } from "../plugin-store.js";
|
||||
|
||||
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
||||
const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
||||
|
||||
export const BUNDLED_PLUGIN_IDS = [
|
||||
"fusion-plugin-dependency-graph",
|
||||
"fusion-plugin-reports",
|
||||
"fusion-plugin-whatsapp-chat",
|
||||
"fusion-plugin-roadmap",
|
||||
"fusion-plugin-hermes-runtime",
|
||||
"fusion-plugin-openclaw-runtime",
|
||||
"fusion-plugin-paperclip-runtime",
|
||||
"fusion-plugin-cursor-runtime",
|
||||
"fusion-plugin-cli-printing-press",
|
||||
"fusion-plugin-compound-engineering",
|
||||
"fusion-plugin-linear-import",
|
||||
] as const;
|
||||
|
||||
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
|
||||
|
||||
export function isBundledPluginId(id: string): id is BundledPluginId {
|
||||
return (BUNDLED_PLUGIN_IDS as readonly string[]).includes(id);
|
||||
}
|
||||
|
||||
export type EnsureBundledResult =
|
||||
| "installed"
|
||||
| "updated"
|
||||
| "already-installed"
|
||||
| "missing-bundle";
|
||||
|
||||
/** Host-supplied resolver: given a plugin id, return candidate directories to probe for `manifest.json`. */
|
||||
export type BundledPluginDirResolver = (pluginId: string) => string[];
|
||||
|
||||
async function loadManifest(pluginDir: string): Promise<PluginManifest> {
|
||||
const manifestPath = join(pluginDir, "manifest.json");
|
||||
const content = await readFile(manifestPath, "utf-8");
|
||||
const manifest = JSON.parse(content);
|
||||
const validation = validatePluginManifest(manifest);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function resolveBundledPluginDir(pluginId: string, getCandidatePluginDirs: BundledPluginDirResolver): string | null {
|
||||
for (const path of getCandidatePluginDirs(pluginId)) {
|
||||
if (existsSync(join(path, "manifest.json"))) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isDirectoryPath(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a bundled runtime plugin is registered (and, if enabled, loaded) in the
|
||||
* given `pluginStore`/`pluginLoader`. The only host-specific input is
|
||||
* `getCandidatePluginDirs`, which returns the ordered list of directories to probe
|
||||
* for a `manifest.json` for the given plugin id — the CLI supplies its
|
||||
* `<cli>/dist/plugins/<id>` search paths, desktop supplies its
|
||||
* `node_modules/@fusion-plugin-examples/<short>` resolution. See `resolvePluginEntryPath`
|
||||
* (also in `@fusion/core`) for the loadable-entry-file selection this helper reuses
|
||||
* rather than re-duplicating.
|
||||
*/
|
||||
export async function ensureBundledPluginInstalled(
|
||||
pluginStore: PluginStore,
|
||||
pluginLoader: PluginLoader,
|
||||
pluginId: string,
|
||||
getCandidatePluginDirs: BundledPluginDirResolver,
|
||||
): Promise<EnsureBundledResult> {
|
||||
let existingPlugin: PluginInstallation | null = null;
|
||||
try {
|
||||
existingPlugin = await pluginStore.getPlugin(pluginId);
|
||||
} catch {
|
||||
// Continue; plugin not installed yet.
|
||||
}
|
||||
|
||||
const bundledDir = resolveBundledPluginDir(pluginId, getCandidatePluginDirs);
|
||||
if (!bundledDir) {
|
||||
return "missing-bundle";
|
||||
}
|
||||
|
||||
const manifest = await loadManifest(bundledDir);
|
||||
const entryPath = resolvePluginEntryPath(bundledDir);
|
||||
|
||||
if (!entryPath) {
|
||||
console.warn(`[plugins] Bundled plugin "${pluginId}" is missing a loadable entry file in ${bundledDir}`);
|
||||
return "missing-bundle";
|
||||
}
|
||||
|
||||
if (existingPlugin) {
|
||||
const existingPathIsDirectory = isDirectoryPath(existingPlugin.path);
|
||||
const pathChanged = existingPathIsDirectory || existingPlugin.path !== entryPath;
|
||||
const versionChanged = existingPlugin.version !== manifest.version;
|
||||
|
||||
if (!pathChanged && !versionChanged) {
|
||||
if (existingPlugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
|
||||
}
|
||||
}
|
||||
return "already-installed";
|
||||
}
|
||||
|
||||
await pluginStore.updatePlugin(pluginId, {
|
||||
...(pathChanged ? { path: entryPath } : {}),
|
||||
...(versionChanged ? { version: manifest.version } : {}),
|
||||
});
|
||||
|
||||
if (existingPlugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", existingPlugin.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
return "updated";
|
||||
}
|
||||
|
||||
const plugin = await pluginStore.registerPlugin({
|
||||
manifest,
|
||||
path: entryPath,
|
||||
});
|
||||
|
||||
if (plugin.enabled) {
|
||||
try {
|
||||
await pluginLoader.loadPlugin(plugin.id);
|
||||
} catch (err) {
|
||||
console.warn("[plugins] failed to load bundled plugin", plugin.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
return "installed";
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link ensureBundledPluginInstalled} with the explicit plugin id.
|
||||
* Kept for backwards compatibility with existing call sites.
|
||||
*/
|
||||
export async function ensureBundledDependencyGraphPluginInstalled(
|
||||
pluginStore: PluginStore,
|
||||
pluginLoader: PluginLoader,
|
||||
getCandidatePluginDirs: BundledPluginDirResolver,
|
||||
): Promise<EnsureBundledResult> {
|
||||
return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID, getCandidatePluginDirs);
|
||||
}
|
||||
|
||||
export async function ensureBundledCursorRuntimePluginInstalled(
|
||||
pluginStore: PluginStore,
|
||||
pluginLoader: PluginLoader,
|
||||
getCandidatePluginDirs: BundledPluginDirResolver,
|
||||
): Promise<EnsureBundledResult> {
|
||||
return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID, getCandidatePluginDirs);
|
||||
}
|
||||
59
packages/desktop/src/__tests__/bundled-plugin-dirs.test.ts
Normal file
59
packages/desktop/src/__tests__/bundled-plugin-dirs.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveDesktopBundlePluginDirs } from "../bundled-plugin-dirs.js";
|
||||
|
||||
/*
|
||||
* FNXC:DesktopRuntime 2026-07-07-12:30:
|
||||
* FN-7637: bundled-plugin-dirs.ts's resolveDesktopBundlePluginDirs takes an injectable
|
||||
* `resolveSpecifier` (defaulting to import.meta.resolve) purely so unit tests can drive it
|
||||
* deterministically without needing the real @fusion-plugin-examples/* packages built/staged
|
||||
* in this dev worktree. Asserts the manifest-id -> npm-package-short-name transform and the
|
||||
* "walk up two directories from the resolved dist/index.js entry to the package root"
|
||||
* derivation that the desktop wiring in local-runtime.ts / local-server.ts depends on.
|
||||
*/
|
||||
describe("resolveDesktopBundlePluginDirs", () => {
|
||||
it("maps a manifest id to its @fusion-plugin-examples/<short-name> package and walks up to the package root", () => {
|
||||
const fakeEntryPath = "/desktop/deploy/node_modules/@fusion-plugin-examples/hermes-runtime/dist/index.js";
|
||||
let requestedSpecifier: string | undefined;
|
||||
const dirs = resolveDesktopBundlePluginDirs("fusion-plugin-hermes-runtime", (specifier) => {
|
||||
requestedSpecifier = specifier;
|
||||
return pathToFileURL(fakeEntryPath).href;
|
||||
});
|
||||
|
||||
expect(requestedSpecifier).toBe("@fusion-plugin-examples/hermes-runtime");
|
||||
expect(dirs).toEqual(["/desktop/deploy/node_modules/@fusion-plugin-examples/hermes-runtime"]);
|
||||
});
|
||||
|
||||
it("returns an empty candidate list when the package is not resolvable (e.g. desktop does not bundle it)", () => {
|
||||
const dirs = resolveDesktopBundlePluginDirs("fusion-plugin-reports", () => {
|
||||
throw new Error("Cannot find package '@fusion-plugin-examples/reports'");
|
||||
});
|
||||
|
||||
expect(dirs).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives the correct short name for every bundled plugin id shape", () => {
|
||||
const seen: string[] = [];
|
||||
const resolver = (specifier: string) => {
|
||||
seen.push(specifier);
|
||||
return pathToFileURL(`/x/node_modules/${specifier}/dist/index.js`).href;
|
||||
};
|
||||
|
||||
resolveDesktopBundlePluginDirs("fusion-plugin-dependency-graph", resolver);
|
||||
resolveDesktopBundlePluginDirs("fusion-plugin-cli-printing-press", resolver);
|
||||
resolveDesktopBundlePluginDirs("fusion-plugin-openclaw-runtime", resolver);
|
||||
|
||||
expect(seen).toEqual([
|
||||
"@fusion-plugin-examples/dependency-graph",
|
||||
"@fusion-plugin-examples/cli-printing-press",
|
||||
"@fusion-plugin-examples/openclaw-runtime",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves for real using the default import.meta.resolve when the package genuinely is not installed", () => {
|
||||
// No injected resolver: exercises the real default (import.meta.resolve). This package is
|
||||
// not staged in this dev worktree, so the real resolver throws and this must fail soft to [].
|
||||
const dirs = resolveDesktopBundlePluginDirs("fusion-plugin-does-not-exist-in-this-worktree");
|
||||
expect(dirs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,14 @@ const engineMocks = vi.hoisted(() => {
|
||||
}));
|
||||
const createServer = vi.fn(() => ({ listen: vi.fn() }));
|
||||
|
||||
// FN-7637: bundled-plugin auto-install mocks proving createDashboardServerDefault wires
|
||||
// ensureBundledPluginInstalled/isBundledPluginId from @fusion/core into both the startup
|
||||
// auto-install pass (Dependency Graph before loadAllPlugins) and the createServer(...)
|
||||
// callback option consumed by PUT /api/plugins/:id/settings.
|
||||
const ensureBundledPluginInstalled = vi.fn(async () => "installed" as const);
|
||||
const isBundledPluginId = vi.fn((id: string) => id.startsWith("fusion-plugin-"));
|
||||
const resolveDesktopBundlePluginDirs = vi.fn((pluginId: string) => [`/desktop/node_modules/@fusion-plugin-examples/${pluginId.replace(/^fusion-plugin-/, "")}`]);
|
||||
|
||||
return {
|
||||
centralCore,
|
||||
engineManager,
|
||||
@@ -97,10 +105,19 @@ const engineMocks = vi.hoisted(() => {
|
||||
pluginStoreInstance,
|
||||
pluginLoaderInstance,
|
||||
runPluginSchemaInits,
|
||||
ensureBundledPluginInstalled,
|
||||
isBundledPluginId,
|
||||
resolveDesktopBundlePluginDirs,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", () => ({ CentralCore: engineMocks.CentralCore, PluginLoader: engineMocks.PluginLoader }));
|
||||
vi.mock("@fusion/core", () => ({
|
||||
CentralCore: engineMocks.CentralCore,
|
||||
PluginLoader: engineMocks.PluginLoader,
|
||||
ensureBundledPluginInstalled: engineMocks.ensureBundledPluginInstalled,
|
||||
isBundledPluginId: engineMocks.isBundledPluginId,
|
||||
}));
|
||||
vi.mock("../bundled-plugin-dirs.js", () => ({ resolveDesktopBundlePluginDirs: engineMocks.resolveDesktopBundlePluginDirs }));
|
||||
vi.mock("@fusion/dashboard", () => ({ createServer: engineMocks.createServer }));
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
ProjectEngineManager: engineMocks.ProjectEngineManager,
|
||||
@@ -563,4 +580,168 @@ describe("LocalRuntimeManager", () => {
|
||||
|
||||
await manager.stopLocal();
|
||||
});
|
||||
|
||||
/*
|
||||
* FN-7637 symptom verification: before this fix, createDashboardServerDefault never invoked
|
||||
* ensureBundledPluginInstalled and never passed an ensureBundledPluginInstalled callback into
|
||||
* createServer(...), so bundled runtime plugins (Dependency Graph, Hermes, OpenClaw, Paperclip, …)
|
||||
* were never auto-installed on desktop the way the CLI dashboard command auto-installs them.
|
||||
* Assert the fix holds in BOTH the engine-less (zero-projects) and projects-present startup
|
||||
* states, since auto-install must run independent of whether a primary engine resolved.
|
||||
*/
|
||||
it("auto-installs the bundled Dependency Graph plugin and wires ensureBundledPluginInstalled into createServer when engine-less (zero projects) (FN-7637)", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
const server = new FakeServer(4545);
|
||||
engineMocks.createServer.mockReturnValueOnce({
|
||||
listen: vi.fn(() => {
|
||||
setTimeout(() => server.emit("listening"), 0);
|
||||
return server as unknown as Server;
|
||||
}),
|
||||
});
|
||||
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async () => store,
|
||||
});
|
||||
|
||||
await manager.startLocal();
|
||||
|
||||
expect(engineMocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
|
||||
engineMocks.pluginStoreInstance,
|
||||
engineMocks.pluginLoaderInstance,
|
||||
"fusion-plugin-dependency-graph",
|
||||
engineMocks.resolveDesktopBundlePluginDirs,
|
||||
);
|
||||
expect(engineMocks.createServer).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ ensureBundledPluginInstalled: expect.any(Function) }),
|
||||
);
|
||||
|
||||
await manager.stopLocal();
|
||||
});
|
||||
|
||||
it("auto-installs the bundled Dependency Graph plugin and wires ensureBundledPluginInstalled into createServer when a project engine resolved (projects-present) (FN-7637)", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
engineMocks.centralCore.listProjects.mockResolvedValueOnce([
|
||||
{ id: "project-1", name: "Repo", path: "/repo", status: "active" },
|
||||
]);
|
||||
const server = new FakeServer(4545);
|
||||
engineMocks.createServer.mockReturnValueOnce({
|
||||
listen: vi.fn(() => {
|
||||
setTimeout(() => server.emit("listening"), 0);
|
||||
return server as unknown as Server;
|
||||
}),
|
||||
});
|
||||
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async () => store,
|
||||
});
|
||||
|
||||
await manager.startLocal();
|
||||
|
||||
expect(engineMocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
|
||||
engineMocks.pluginStoreInstance,
|
||||
engineMocks.pluginLoaderInstance,
|
||||
"fusion-plugin-dependency-graph",
|
||||
engineMocks.resolveDesktopBundlePluginDirs,
|
||||
);
|
||||
expect(engineMocks.createServer).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
engine: expect.anything(),
|
||||
ensureBundledPluginInstalled: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.stopLocal();
|
||||
});
|
||||
|
||||
it("the wired ensureBundledPluginInstalled callback delegates to the shared helper for a lazy-install id (FN-7637)", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
const server = new FakeServer(4545);
|
||||
engineMocks.createServer.mockReturnValueOnce({
|
||||
listen: vi.fn(() => {
|
||||
setTimeout(() => server.emit("listening"), 0);
|
||||
return server as unknown as Server;
|
||||
}),
|
||||
});
|
||||
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async () => store,
|
||||
});
|
||||
|
||||
await manager.startLocal();
|
||||
|
||||
const callOptions = engineMocks.createServer.mock.calls[0]?.[1] as { ensureBundledPluginInstalled: (id: string) => Promise<boolean> };
|
||||
engineMocks.ensureBundledPluginInstalled.mockClear();
|
||||
engineMocks.ensureBundledPluginInstalled.mockResolvedValueOnce("installed");
|
||||
|
||||
const result = await callOptions.ensureBundledPluginInstalled("fusion-plugin-hermes-runtime");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(engineMocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
|
||||
engineMocks.pluginStoreInstance,
|
||||
engineMocks.pluginLoaderInstance,
|
||||
"fusion-plugin-hermes-runtime",
|
||||
engineMocks.resolveDesktopBundlePluginDirs,
|
||||
);
|
||||
|
||||
await manager.stopLocal();
|
||||
});
|
||||
|
||||
it("the wired ensureBundledPluginInstalled callback returns false for a missing bundle (FN-7637)", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
const server = new FakeServer(4545);
|
||||
engineMocks.createServer.mockReturnValueOnce({
|
||||
listen: vi.fn(() => {
|
||||
setTimeout(() => server.emit("listening"), 0);
|
||||
return server as unknown as Server;
|
||||
}),
|
||||
});
|
||||
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async () => store,
|
||||
});
|
||||
|
||||
await manager.startLocal();
|
||||
|
||||
const callOptions = engineMocks.createServer.mock.calls[0]?.[1] as { ensureBundledPluginInstalled: (id: string) => Promise<boolean> };
|
||||
engineMocks.ensureBundledPluginInstalled.mockResolvedValueOnce("missing-bundle");
|
||||
|
||||
const result = await callOptions.ensureBundledPluginInstalled("fusion-plugin-reports");
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
await manager.stopLocal();
|
||||
});
|
||||
|
||||
it("does not wire ensureBundledPluginInstalled into createServer when the plugin subsystem fails to init (fail-soft) (FN-7637)", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
engineMocks.pluginStoreInstance.init.mockRejectedValueOnce(new Error("plugin db locked"));
|
||||
const server = new FakeServer(4545);
|
||||
engineMocks.createServer.mockReturnValueOnce({
|
||||
listen: vi.fn(() => {
|
||||
setTimeout(() => server.emit("listening"), 0);
|
||||
return server as unknown as Server;
|
||||
}),
|
||||
});
|
||||
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async () => store,
|
||||
});
|
||||
|
||||
const status = await manager.startLocal();
|
||||
|
||||
expect(status).toMatchObject({ source: "embedded-local", state: "running", port: 4545 });
|
||||
expect(engineMocks.createServer).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.not.objectContaining({ ensureBundledPluginInstalled: expect.anything() }),
|
||||
);
|
||||
|
||||
await manager.stopLocal();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,14 @@ const mocks = vi.hoisted(() => {
|
||||
return pluginLoaderInstance;
|
||||
});
|
||||
|
||||
// FN-7637: bundled-plugin auto-install mocks proving local-server.ts wires
|
||||
// ensureBundledPluginInstalled/isBundledPluginId from @fusion/core into both the startup
|
||||
// auto-install pass (Dependency Graph before loadAllPlugins) and the createServer(...)
|
||||
// callback option consumed by PUT /api/plugins/:id/settings.
|
||||
const ensureBundledPluginInstalled = vi.fn(async () => "installed" as const);
|
||||
const isBundledPluginId = vi.fn((id: string) => id.startsWith("fusion-plugin-"));
|
||||
const resolveDesktopBundlePluginDirs = vi.fn((pluginId: string) => [`/desktop/node_modules/@fusion-plugin-examples/${pluginId.replace(/^fusion-plugin-/, "")}`]);
|
||||
|
||||
const store = {
|
||||
init: vi.fn(async () => undefined),
|
||||
watch: vi.fn(async () => undefined),
|
||||
@@ -127,10 +135,20 @@ const mocks = vi.hoisted(() => {
|
||||
runPluginSchemaInits,
|
||||
seedDashboardProviders,
|
||||
seedDashboardProvidersDispose,
|
||||
ensureBundledPluginInstalled,
|
||||
isBundledPluginId,
|
||||
resolveDesktopBundlePluginDirs,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore, CentralCore: mocks.CentralCore, PluginLoader: mocks.PluginLoader }));
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: mocks.TaskStore,
|
||||
CentralCore: mocks.CentralCore,
|
||||
PluginLoader: mocks.PluginLoader,
|
||||
ensureBundledPluginInstalled: mocks.ensureBundledPluginInstalled,
|
||||
isBundledPluginId: mocks.isBundledPluginId,
|
||||
}));
|
||||
vi.mock("../bundled-plugin-dirs.js", () => ({ resolveDesktopBundlePluginDirs: mocks.resolveDesktopBundlePluginDirs }));
|
||||
vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServer }));
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
ProjectEngineManager: mocks.ProjectEngineManager,
|
||||
@@ -312,4 +330,80 @@ describe("DesktopLocalServerManager", () => {
|
||||
expect.not.objectContaining({ pluginStore: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
* FN-7637 symptom verification: before this fix, DesktopLocalServerManager.start() never invoked
|
||||
* ensureBundledPluginInstalled and never passed an ensureBundledPluginInstalled callback into
|
||||
* createServer(...), so bundled runtime plugins (Dependency Graph, Hermes, OpenClaw, Paperclip, …)
|
||||
* were never auto-installed on desktop the way they are under the CLI dashboard command. Assert the
|
||||
* fix: the startup pass calls the shared helper for the bundled Dependency Graph id using the
|
||||
* desktop bundle-dir resolver, and createServer receives a callable ensureBundledPluginInstalled
|
||||
* option.
|
||||
*/
|
||||
it("auto-installs the bundled Dependency Graph plugin at startup and wires ensureBundledPluginInstalled into createServer (FN-7637)", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(mocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
|
||||
mocks.pluginStoreInstance,
|
||||
mocks.pluginLoaderInstance,
|
||||
"fusion-plugin-dependency-graph",
|
||||
mocks.resolveDesktopBundlePluginDirs,
|
||||
);
|
||||
expect(mocks.createServer).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ ensureBundledPluginInstalled: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("the wired ensureBundledPluginInstalled callback delegates to the shared helper for a lazy-install id", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
await manager.start();
|
||||
|
||||
const callOptions = mocks.createServer.mock.calls[0]?.[1] as { ensureBundledPluginInstalled: (id: string) => Promise<boolean> };
|
||||
mocks.ensureBundledPluginInstalled.mockClear();
|
||||
mocks.ensureBundledPluginInstalled.mockResolvedValueOnce("installed");
|
||||
|
||||
const result = await callOptions.ensureBundledPluginInstalled("fusion-plugin-hermes-runtime");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mocks.ensureBundledPluginInstalled).toHaveBeenCalledWith(
|
||||
mocks.pluginStoreInstance,
|
||||
mocks.pluginLoaderInstance,
|
||||
"fusion-plugin-hermes-runtime",
|
||||
mocks.resolveDesktopBundlePluginDirs,
|
||||
);
|
||||
});
|
||||
|
||||
it("the wired ensureBundledPluginInstalled callback returns false for a missing bundle", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
await manager.start();
|
||||
|
||||
const callOptions = mocks.createServer.mock.calls[0]?.[1] as { ensureBundledPluginInstalled: (id: string) => Promise<boolean> };
|
||||
mocks.ensureBundledPluginInstalled.mockResolvedValueOnce("missing-bundle");
|
||||
|
||||
const result = await callOptions.ensureBundledPluginInstalled("fusion-plugin-reports");
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("does not wire ensureBundledPluginInstalled into createServer when the plugin subsystem fails to init (fail-soft)", async () => {
|
||||
mocks.pluginStoreInstance.init.mockRejectedValueOnce(new Error("plugin db locked"));
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
const runtime = await manager.start();
|
||||
|
||||
expect(runtime.port).toBe(4545);
|
||||
expect(mocks.createServer).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.not.objectContaining({ ensureBundledPluginInstalled: expect.anything() }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
61
packages/desktop/src/bundled-plugin-dirs.ts
Normal file
61
packages/desktop/src/bundled-plugin-dirs.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* FNXC:DesktopRuntime 2026-07-07-12:30:
|
||||
* FN-7637: bundle-directory resolution is the ONLY host-specific input the shared
|
||||
* @fusion/core `ensureBundledPluginInstalled` helper needs (see packages/core/src/plugins/bundled-plugin-install.ts).
|
||||
* The CLI stages bundled plugins under `<cli>/dist/plugins/<manifest-id>/`; desktop's
|
||||
* packaged closure instead carries the same plugins as `@fusion-plugin-examples/<short-name>`
|
||||
* workspace packages (dependencies of `@fusion/dashboard`, materialized into the desktop
|
||||
* `pnpm deploy` closure by `packages/desktop/scripts/workspace-tools.ts#stageDesktopDeploy` —
|
||||
* see FN-7637 Step 1 investigation, task document "decision"). Every bundled plugin's
|
||||
* manifest `id` is `fusion-plugin-<short-name>` and its npm package name is
|
||||
* `@fusion-plugin-examples/<short-name>` — a mechanical transform, not a hardcoded table.
|
||||
*
|
||||
* Resolution uses `import.meta.resolve` against the package's declared "." export
|
||||
* (which points at `./dist/index.js`) rather than manual node_modules traversal, so it
|
||||
* works correctly under both flat/hoisted (desktop deploy) and nested (workspace dev)
|
||||
* `node_modules` layouts. The manifest.json sits at the package root (one directory above
|
||||
* `dist/`), so the resolved dist/index.js path is walked up two directories to get the
|
||||
* candidate bundle directory. A plugin that desktop does not depend on (e.g. `reports`,
|
||||
* `whatsapp-chat`, `linear-import` — not `@fusion/dashboard` deps) has no resolvable
|
||||
* package and this returns an empty candidate list, which `ensureBundledPluginInstalled`
|
||||
* correctly reports as `missing-bundle` (parity with the CLI's "not found in this build").
|
||||
*/
|
||||
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Manifest id (e.g. "fusion-plugin-hermes-runtime") -> npm package short name (e.g. "hermes-runtime"). */
|
||||
function toPackageShortName(pluginId: string): string {
|
||||
return pluginId.replace(/^fusion-plugin-/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the candidate bundle directory (directory containing `manifest.json`) for a
|
||||
* bundled plugin id under the desktop/dashboard `node_modules` resolution root. Returns
|
||||
* an empty array when the corresponding `@fusion-plugin-examples/<short-name>` package is
|
||||
* not installed in this closure (desktop does not bundle every CLI-bundled plugin).
|
||||
*
|
||||
* `resolveSpecifier` defaults to this module's own `import.meta.resolve` (Node exposes it as
|
||||
* a writable/configurable own property) and is overridable purely for unit testing — each ES
|
||||
* module has its own distinct `import.meta`, so a caller/test module cannot patch this
|
||||
* module's resolver from the outside without an injectable seam.
|
||||
*/
|
||||
export function resolveDesktopBundlePluginDirs(
|
||||
pluginId: string,
|
||||
resolveSpecifier: (specifier: string) => string = import.meta.resolve,
|
||||
): string[] {
|
||||
const packageName = `@fusion-plugin-examples/${toPackageShortName(pluginId)}`;
|
||||
try {
|
||||
// import.meta.resolve applies Node's package "exports" resolution algorithm without
|
||||
// requiring the target file to exist on disk, so this works even before `dist/` is built
|
||||
// in a dev worktree — it only fails (throws) when the package itself isn't resolvable.
|
||||
const resolvedUrl = resolveSpecifier(packageName);
|
||||
const resolvedEntryPath = fileURLToPath(resolvedUrl);
|
||||
// resolvedEntryPath is ".../<package-root>/dist/index.js"; the package root (where
|
||||
// manifest.json lives) is two directories up.
|
||||
const packageRoot = dirname(dirname(resolvedEntryPath));
|
||||
return [packageRoot];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { resolveDesktopRuntimePrimaryProject } from "./engine-runtime.js";
|
||||
import { resolveDesktopBundlePluginDirs } from "./bundled-plugin-dirs.js";
|
||||
|
||||
/*
|
||||
* FNXC:DesktopRuntime 2026-07-02-14:35:
|
||||
@@ -100,7 +101,7 @@ async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
|
||||
}
|
||||
|
||||
async function createDashboardServerDefault(store: TaskStoreLike, rootDir: string): Promise<{ server: Server; cleanup: RuntimeCleanup }> {
|
||||
const { CentralCore, PluginLoader } = await import("@fusion/core");
|
||||
const { CentralCore, PluginLoader, ensureBundledPluginInstalled, isBundledPluginId } = await import("@fusion/core");
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine");
|
||||
|
||||
@@ -168,19 +169,49 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
|
||||
* FN-7623: mirror the CLI dashboard command's plugin wiring (packages/cli/src/commands/dashboard.ts)
|
||||
* — construct the store's PluginStore, build a PluginLoader, load enabled plugins, and run schema-init
|
||||
* hooks — so the desktop embedded server's registry sub-router mounts (GET /api/plugins/registry) and
|
||||
* POST /api/plugins install mode works. Bundled-plugin auto-install (Hermes/OpenClaw/Paperclip/Dependency
|
||||
* Graph) depends on packages/cli/src/plugins/bundled-plugin-install.ts, which is CLI-only and out of
|
||||
* scope for desktop (desktop must not depend on the CLI package) — see FN-7623 scope note. Failures here
|
||||
* must not crash embedded startup: the dashboard still needs to boot even if the plugin subsystem can't
|
||||
* come up (e.g. a corrupt plugin manifest), so this is wrapped and traced rather than left to throw.
|
||||
* POST /api/plugins install mode works. Failures here must not crash embedded startup: the dashboard
|
||||
* still needs to boot even if the plugin subsystem can't come up (e.g. a corrupt plugin manifest), so
|
||||
* this is wrapped and traced rather than left to throw.
|
||||
*
|
||||
* FNXC:DesktopRuntime 2026-07-07-12:30:
|
||||
* FN-7637: bundled-plugin auto-install (Dependency Graph, Hermes, OpenClaw, Paperclip, …) is now
|
||||
* host-agnostic in @fusion/core's ensureBundledPluginInstalled. The only host-specific input is
|
||||
* bundle-directory resolution: resolveDesktopBundlePluginDirs (./bundled-plugin-dirs.js) resolves
|
||||
* each manifest id to its staged `@fusion-plugin-examples/<short-name>` package directory via
|
||||
* import.meta.resolve, mirroring the CLI's `<cli>/dist/plugins/<id>` resolver
|
||||
* (packages/cli/src/plugins/bundled-plugin-install.ts). Mirrors the CLI dashboard command's startup
|
||||
* auto-install pass: install the bundled Dependency Graph plugin before loadAllPlugins() so it is
|
||||
* enabled/registered before the general load pass runs, and expose the same lazy-install callback the
|
||||
* CLI wires so PUT /api/plugins/:id/settings can auto-install Hermes/OpenClaw/Paperclip/etc. on first
|
||||
* save. Cross-reference: local-server.ts carries the matching wiring for the other desktop startup path.
|
||||
*/
|
||||
let pluginStore: PluginStoreLike | undefined;
|
||||
let pluginLoader: InstanceType<typeof PluginLoader> | undefined;
|
||||
let ensureBundledPluginInstalledCallback: ((pluginId: string) => Promise<boolean>) | undefined;
|
||||
try {
|
||||
strace("createDashboardServer: pluginStore.init");
|
||||
pluginStore = store.getPluginStore();
|
||||
await pluginStore.init();
|
||||
pluginLoader = new PluginLoader({ pluginStore: pluginStore as never, taskStore: store as never });
|
||||
|
||||
const boundPluginStore = pluginStore;
|
||||
const boundPluginLoader = pluginLoader;
|
||||
|
||||
try {
|
||||
strace("createDashboardServer: bundled dependency-graph auto-install");
|
||||
const installStatus = await ensureBundledPluginInstalled(
|
||||
boundPluginStore as never,
|
||||
boundPluginLoader,
|
||||
"fusion-plugin-dependency-graph",
|
||||
resolveDesktopBundlePluginDirs,
|
||||
);
|
||||
strace(`createDashboardServer: bundled dependency-graph auto-install status=${installStatus}`);
|
||||
} catch (error) {
|
||||
strace(
|
||||
`createDashboardServer: bundled dependency-graph auto-install FAILED (non-fatal) — ${error instanceof Error ? error.stack : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
strace("createDashboardServer: pluginLoader.loadAllPlugins");
|
||||
const { loaded, errors } = await pluginLoader.loadAllPlugins();
|
||||
strace(`createDashboardServer: plugins loaded=${loaded} errors=${errors}`);
|
||||
@@ -188,12 +219,34 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
|
||||
if (schemaHooks.length > 0) {
|
||||
await store.getDatabase().runPluginSchemaInits(schemaHooks);
|
||||
}
|
||||
|
||||
ensureBundledPluginInstalledCallback = async (pluginId: string): Promise<boolean> => {
|
||||
if (!isBundledPluginId(pluginId)) {
|
||||
strace(`ensureBundledPluginInstalled: unknown bundled plugin id "${pluginId}"`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const status = await ensureBundledPluginInstalled(boundPluginStore as never, boundPluginLoader, pluginId, resolveDesktopBundlePluginDirs);
|
||||
if (status === "missing-bundle") {
|
||||
strace(`ensureBundledPluginInstalled: bundled plugin "${pluginId}" not found in this build`);
|
||||
return false;
|
||||
}
|
||||
strace(`ensureBundledPluginInstalled: bundled plugin "${pluginId}" status=${status}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
strace(
|
||||
`ensureBundledPluginInstalled: failed to auto-install "${pluginId}" — ${error instanceof Error ? error.stack : String(error)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
strace(
|
||||
`createDashboardServer: plugin subsystem init FAILED (non-fatal, dashboard still boots) — ${error instanceof Error ? error.stack : String(error)}`,
|
||||
);
|
||||
pluginStore = undefined;
|
||||
pluginLoader = undefined;
|
||||
ensureBundledPluginInstalledCallback = undefined;
|
||||
}
|
||||
|
||||
strace("createDashboardServer: createServer");
|
||||
@@ -204,6 +257,7 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
|
||||
authStorage: wrappedAuthStorage,
|
||||
modelRegistry,
|
||||
...(pluginStore && pluginLoader ? { pluginStore: pluginStore as never, pluginLoader, pluginRunner: pluginLoader } : {}),
|
||||
...(ensureBundledPluginInstalledCallback ? { ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback } : {}),
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { once } from "node:events";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
import { resolveDesktopRuntimePrimaryProject } from "./engine-runtime.js";
|
||||
import { resolveDesktopBundlePluginDirs } from "./bundled-plugin-dirs.js";
|
||||
|
||||
/*
|
||||
* FNXC:DesktopRuntime 2026-07-07-12:00:
|
||||
@@ -66,7 +67,7 @@ export class DesktopLocalServerManager {
|
||||
|
||||
try {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
const { CentralCore, PluginLoader } = await import("@fusion/core");
|
||||
const { CentralCore, PluginLoader, ensureBundledPluginInstalled, isBundledPluginId } = await import("@fusion/core");
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine");
|
||||
store = new TaskStore(this.rootDir) as TaskStoreLike;
|
||||
@@ -113,22 +114,54 @@ export class DesktopLocalServerManager {
|
||||
* build a PluginLoader, load enabled plugins, and run schema-init hooks — so this legacy path's
|
||||
* registry sub-router mounts and install works too. Fail soft: a broken plugin subsystem must not
|
||||
* prevent the embedded dashboard from booting.
|
||||
*
|
||||
* FNXC:DesktopRuntime 2026-07-07-12:30:
|
||||
* FN-7637: mirror local-runtime.ts's bundled-plugin auto-install wiring so BOTH desktop startup
|
||||
* paths auto-install bundled runtime plugins (Dependency Graph, Hermes, OpenClaw, Paperclip, …)
|
||||
* identically — same shared @fusion/core helper, same resolveDesktopBundlePluginDirs resolver, same
|
||||
* lazy-install callback exposed to PUT /api/plugins/:id/settings. See local-runtime.ts's matching
|
||||
* comment for the full rationale.
|
||||
*/
|
||||
let pluginStore: PluginStoreLike | undefined;
|
||||
let pluginLoader: InstanceType<typeof PluginLoader> | undefined;
|
||||
let ensureBundledPluginInstalledCallback: ((pluginId: string) => Promise<boolean>) | undefined;
|
||||
try {
|
||||
pluginStore = store.getPluginStore();
|
||||
await pluginStore.init();
|
||||
pluginLoader = new PluginLoader({ pluginStore: pluginStore as never, taskStore: store as never });
|
||||
|
||||
const boundPluginStore = pluginStore;
|
||||
const boundPluginLoader = pluginLoader;
|
||||
|
||||
try {
|
||||
await ensureBundledPluginInstalled(
|
||||
boundPluginStore as never,
|
||||
boundPluginLoader,
|
||||
"fusion-plugin-dependency-graph",
|
||||
resolveDesktopBundlePluginDirs,
|
||||
);
|
||||
} catch {
|
||||
// Bundled dependency-graph auto-install failure must not block startup (FN-7637, mirrors FN-7623 fail-soft).
|
||||
}
|
||||
|
||||
await pluginLoader.loadAllPlugins();
|
||||
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
|
||||
if (schemaHooks.length > 0) {
|
||||
await store.getDatabase().runPluginSchemaInits(schemaHooks);
|
||||
}
|
||||
|
||||
ensureBundledPluginInstalledCallback = async (pluginId: string): Promise<boolean> => {
|
||||
if (!isBundledPluginId(pluginId)) {
|
||||
return false;
|
||||
}
|
||||
const status = await ensureBundledPluginInstalled(boundPluginStore as never, boundPluginLoader, pluginId, resolveDesktopBundlePluginDirs);
|
||||
return status !== "missing-bundle";
|
||||
};
|
||||
} catch {
|
||||
// Plugin subsystem failures must not block embedded dashboard startup (FN-7623).
|
||||
pluginStore = undefined;
|
||||
pluginLoader = undefined;
|
||||
ensureBundledPluginInstalledCallback = undefined;
|
||||
}
|
||||
|
||||
const app = createServer(store as never, {
|
||||
@@ -138,6 +171,7 @@ export class DesktopLocalServerManager {
|
||||
authStorage: wrappedAuthStorage,
|
||||
modelRegistry,
|
||||
...(pluginStore && pluginLoader ? { pluginStore: pluginStore as never, pluginLoader, pluginRunner: pluginLoader } : {}),
|
||||
...(ensureBundledPluginInstalledCallback ? { ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback } : {}),
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||
});
|
||||
server = app.listen(0);
|
||||
|
||||
Reference in New Issue
Block a user