fix: pack full desktop dependency closure + resolve bun/desktop plugin loading on Windows

Desktop "Local" mode crashed with ERR_MODULE_NOT_FOUND because electron-builder's
pnpm collector drops `deduped` subtrees, so engine's transitive closure
(@modelcontextprotocol/sdk, pi-ai provider SDKs, etc.) was never packed. Stage the
complete flat prod closure with `pnpm deploy --legacy --config.node-linker=hoisted`
and package it via `electron-builder --projectDir deploy`, bypassing the lossy
collector entirely.

Also make the dashboard-imported example plugins loadable under plain Node (the
Electron main runtime): cursor/droid/roadmap now expose compiled `dist` on the
`import` condition (keeping `source`→src for the bun CLI) and are built during the
desktop build. Add `source` conditions to paperclip/agent-browser/even-cards/
even-realities-glasses/whatsapp-chat so the bun `--conditions=source` Windows CLI
compile resolves them from source.

Validated on macOS: @fusion/core|engine|dashboard import cleanly from the staged
deploy; packing yields a complete 705-package asar; bun-windows-x64 cross-compiles
with all plugin dist removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-01 20:40:02 -07:00
parent 7488e0971f
commit abd596b700
16 changed files with 129 additions and 20 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the Windows CLI binary failing to build in release.
category: fix
dev: The bun `--compile --conditions=source` build could not resolve @fusion-plugin-examples/paperclip-runtime (statically imported by dashboard runtime-provider-probes.ts) because it lacked a `source` export condition and fell through to `import`→`dist/index.js`, absent on the Windows runner. Added `"source": "./src/index.ts"` to paperclip and the remaining example plugins that export `import`→dist (agent-browser, even-cards, even-realities-glasses, whatsapp-chat), matching hermes/openclaw. Verified by cross-compiling bun-windows-x64 with all plugin dist removed.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the desktop app crashing on "Local" mode with missing-module errors.
category: fix
dev: electron-builder's pnpm support runs `pnpm list --prod` and drops `deduped` subtrees, so the embedded runtime's `import("@fusion/engine")` closure (@modelcontextprotocol/sdk, the pi-ai provider SDKs, etc.) was never packed into app.asar. The desktop build now stages the complete flat production closure with `pnpm deploy --legacy --config.node-linker=hoisted` and points electron-builder at it via `--projectDir deploy`, so packaging no longer depends on the lossy collector. Also fixes cursor/droid/roadmap plugin exports to expose compiled `dist` on the `import` condition (with `source`→src kept for the bun CLI) so the dashboard server loads them under plain Node, and builds those plugins in the desktop build. Validated by importing @fusion/core|engine|dashboard from the staged deploy and packing a complete 705-package asar.

View File

@@ -38,7 +38,7 @@ jobs:
# Code-signing hardening is intentionally deferred to FN-5592. # Code-signing hardening is intentionally deferred to FN-5592.
- name: Package signed Windows EXE - name: Package signed Windows EXE
if: ${{ env.WINDOWS_CERTIFICATE_BASE64 != '' }} if: ${{ env.WINDOWS_CERTIFICATE_BASE64 != '' }}
run: pnpm --filter @fusion/desktop exec electron-builder --win --x64 --arm64 --publish never run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --win --x64 --arm64 --publish never
env: env:
WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }} WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}
CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }} CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}
@@ -48,7 +48,7 @@ jobs:
- name: Package unsigned Windows EXE - name: Package unsigned Windows EXE
if: ${{ env.WINDOWS_CERTIFICATE_BASE64 == '' }} if: ${{ env.WINDOWS_CERTIFICATE_BASE64 == '' }}
run: pnpm --filter @fusion/desktop exec electron-builder --win --x64 --arm64 --publish never run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --win --x64 --arm64 --publish never
env: env:
WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }} WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"

View File

@@ -143,7 +143,7 @@ jobs:
# Use `exec electron-builder` rather than the `dist:win` script: pnpm leaks # Use `exec electron-builder` rather than the `dist:win` script: pnpm leaks
# the `--` separator into script args (electron-builder then stops parsing # the `--` separator into script args (electron-builder then stops parsing
# at `--` and ignores `--publish never`, auto-publishing to the wrong repo). # at `--` and ignores `--publish never`, auto-publishing to the wrong repo).
run: pnpm --filter @fusion/desktop exec electron-builder --win --publish never run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --win --publish never
env: env:
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -196,7 +196,7 @@ jobs:
- name: Package signed macOS desktop DMG/ZIP - name: Package signed macOS desktop DMG/ZIP
if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }} if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }}
run: pnpm --filter @fusion/desktop exec electron-builder --mac --publish never run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --mac --publish never
env: env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
@@ -209,7 +209,7 @@ jobs:
- name: Package unsigned macOS desktop DMG/ZIP - name: Package unsigned macOS desktop DMG/ZIP
if: ${{ env.APPLE_CERTIFICATE_BASE64 == '' }} if: ${{ env.APPLE_CERTIFICATE_BASE64 == '' }}
run: pnpm --filter @fusion/desktop exec electron-builder --mac --publish never -c.mac.notarize=false run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --mac --publish never -c.mac.notarize=false
env: env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
@@ -295,7 +295,7 @@ jobs:
- name: Package Linux desktop artifacts - name: Package Linux desktop artifacts
# Linux desktop code-signing is deferred to FN-5605; Linux ARM64 CLI binaries are tracked in FN-5606. # Linux desktop code-signing is deferred to FN-5605; Linux ARM64 CLI binaries are tracked in FN-5606.
run: pnpm --filter @fusion/desktop exec electron-builder --linux --x64 --arm64 --publish never run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --linux --x64 --arm64 --publish never
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -101,3 +101,6 @@ packages/mobile/android/
# MCP server config (contains API keys) # MCP server config (contains API keys)
.mcp.json .mcp.json
# Desktop packaging: staged flat prod closure produced by `@fusion/desktop build` (pnpm deploy)
packages/desktop/deploy/

View File

@@ -29,11 +29,11 @@
"test": "tsx scripts/test.ts", "test": "tsx scripts/test.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"generate:icons": "tsx scripts/generate-icons.ts", "generate:icons": "tsx scripts/generate-icons.ts",
"pack": "electron-builder --dir", "pack": "electron-builder --projectDir deploy --dir",
"dist": "electron-builder", "dist": "electron-builder --projectDir deploy",
"dist:win": "electron-builder --win", "dist:win": "electron-builder --projectDir deploy --win",
"dist:mac": "electron-builder --mac", "dist:mac": "electron-builder --projectDir deploy --mac",
"dist:linux": "electron-builder --linux" "dist:linux": "electron-builder --projectDir deploy --linux"
}, },
"dependencies": { "dependencies": {
"@fusion/core": "workspace:*", "@fusion/core": "workspace:*",

View File

@@ -1,7 +1,7 @@
import { build } from "esbuild"; import { build } from "esbuild";
import { cp, mkdir, rm, stat } from "node:fs/promises"; import { cp, mkdir, rm, stat } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { buildCore, buildDashboard, buildDashboardClient, buildEngine, packageRoot, workspaceRoot } from "./workspace-tools"; import { buildCore, buildDashboard, buildDashboardClient, buildEngine, packageRoot, stageDesktopDeploy, workspaceRoot } from "./workspace-tools";
const dashboardRoot = join(workspaceRoot, "packages", "dashboard"); const dashboardRoot = join(workspaceRoot, "packages", "dashboard");
const dashboardClientDir = join(dashboardRoot, "dist", "client"); const dashboardClientDir = join(dashboardRoot, "dist", "client");
const dashboardRegistryManifestSource = join(dashboardRoot, "src", "registry-manifest.json"); const dashboardRegistryManifestSource = join(dashboardRoot, "src", "registry-manifest.json");
@@ -122,6 +122,12 @@ async function main(): Promise<void> {
await buildElectronEntrypoints(); await buildElectronEntrypoints();
await copyDashboardClient(); await copyDashboardClient();
// FNXC:DesktopPackaging 2026-07-01-21:15:
// Stage the complete flat production closure last (after all dist exists) so
// electron-builder can package it via --projectDir instead of its pnpm collector,
// which drops `deduped` subtrees and left the embedded runtime missing deps.
await stageDesktopDeploy();
console.log("[desktop:build] Desktop build complete"); console.log("[desktop:build] Desktop build complete");
} }

View File

@@ -1,7 +1,7 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { cp } from "node:fs/promises"; import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -62,6 +62,16 @@ async function buildPackage(relativePath: string): Promise<void> {
await runWorkspaceBin("tsc", [], resolve(workspaceRoot, relativePath)); await runWorkspaceBin("tsc", [], resolve(workspaceRoot, relativePath));
} }
// FNXC:DesktopBuild 2026-07-01-21:10:
// Every example plugin whose compiled entry the dashboard SERVER statically
// imports must have dist built, because the packaged desktop runs the dashboard
// under plain Node (Electron main) which cannot load a plugin's `.ts` source.
// The plugins' `import` export condition points at ./dist/*.js (with a `source`
// condition kept for the bun-compiled CLI), so a missing dist => the packaged
// app crashes on Local mode when @fusion/dashboard imports the plugin.
// routes.ts / runtime-provider-probes.ts / droid-cli-probe.ts / roadmap-routes.ts
// pull hermes, openclaw, paperclip, cursor, droid and roadmap; dependency-graph
// backs a dashboard view. Keep this list in sync with dashboard's static plugin imports.
export async function buildDashboardRuntimePlugins(): Promise<void> { export async function buildDashboardRuntimePlugins(): Promise<void> {
await buildPackage("packages/plugin-sdk"); await buildPackage("packages/plugin-sdk");
await Promise.all([ await Promise.all([
@@ -69,6 +79,9 @@ export async function buildDashboardRuntimePlugins(): Promise<void> {
buildPackage("plugins/fusion-plugin-hermes-runtime"), buildPackage("plugins/fusion-plugin-hermes-runtime"),
buildPackage("plugins/fusion-plugin-openclaw-runtime"), buildPackage("plugins/fusion-plugin-openclaw-runtime"),
buildPackage("plugins/fusion-plugin-paperclip-runtime"), buildPackage("plugins/fusion-plugin-paperclip-runtime"),
buildPackage("plugins/fusion-plugin-cursor-runtime"),
buildPackage("plugins/fusion-plugin-droid-runtime"),
buildPackage("plugins/fusion-plugin-roadmap"),
]); ]);
} }
@@ -82,6 +95,67 @@ export async function buildDashboard(): Promise<void> {
await cp(resolve(dashboardRoot, "src", "registry-manifest.json"), resolve(dashboardRoot, "dist", "registry-manifest.json")); await cp(resolve(dashboardRoot, "src", "registry-manifest.json"), resolve(dashboardRoot, "dist", "registry-manifest.json"));
} }
function runPnpm(args: string[], cwd: string): Promise<void> {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn("pnpm", args, {
cwd,
stdio: "inherit",
env: process.env,
// pnpm resolves to a .cmd shim on Windows; Node refuses to spawn it without a shell.
shell: process.platform === "win32",
});
child.on("error", rejectPromise);
child.on("exit", (code) =>
code === 0 ? resolvePromise() : rejectPromise(new Error(`pnpm ${args.join(" ")} exited with code ${code ?? "unknown"}`)),
);
});
}
/*
FNXC:DesktopPackaging 2026-07-01-21:15:
Durable fix for the packaged desktop app missing runtime dependencies.
electron-builder's pnpm support runs `pnpm list --prod` and silently drops
`deduped` subtrees, so the embedded Local runtime's `import("@fusion/engine")`
closure (@modelcontextprotocol/sdk, the pi-ai provider SDKs, etc.) was never
packed and the app crashed with ERR_MODULE_NOT_FOUND. Instead of relying on that
collector, materialize the complete production closure with `pnpm deploy`
(legacy + hoisted => a real flat node_modules that pnpm resolves correctly) and
point electron-builder at that staged directory via --projectDir.
- --prod drops devDependencies (electron itself), so copy electron's package.json
into the stage so electron-builder can still compute the version (it downloads
the runtime separately).
- Rewrite the staged electron-builder.yml output to ../dist-electron so artifacts
still land in packages/desktop/dist-electron for the existing workflow globs.
- The stage has no pnpm-lock, so electron-builder treats it as a plain flat
project and packs the whole node_modules — no dedup gap.
*/
export const desktopDeployDir = resolve(packageRoot, "deploy");
export async function stageDesktopDeploy(): Promise<void> {
console.log("[desktop:build] Staging complete production closure via pnpm deploy...");
await rm(desktopDeployDir, { recursive: true, force: true });
await runPnpm(
["--filter", "@fusion/desktop", "deploy", "--prod", "--legacy", "--config.node-linker=hoisted", desktopDeployDir],
workspaceRoot,
);
// electron-builder needs electron's version; --prod pruned the devDependency.
const electronPkg = resolve(packageRoot, "node_modules", "electron", "package.json");
const stagedElectronDir = resolve(desktopDeployDir, "node_modules", "electron");
await mkdir(stagedElectronDir, { recursive: true });
await cp(electronPkg, resolve(stagedElectronDir, "package.json"));
// Keep artifacts in packages/desktop/dist-electron for the existing workflow globs.
const stagedConfig = resolve(desktopDeployDir, "electron-builder.yml");
const config = await readFile(stagedConfig, "utf8");
const patched = /output:\s*dist-electron/.test(config)
? config.replace(/output:\s*dist-electron/, "output: ../dist-electron")
: `directories:\n output: ../dist-electron\n${config}`;
await writeFile(stagedConfig, patched);
console.log(`[desktop:build] Deploy staged at ${desktopDeployDir}`);
}
export async function buildDashboardClient(): Promise<void> { export async function buildDashboardClient(): Promise<void> {
// Desktop loads index.html via file:// from inside the asar, so absolute // Desktop loads index.html via file:// from inside the asar, so absolute
// asset paths (/assets/...) resolve to the filesystem root and fail. Build // asset paths (/assets/...) resolve to the filesystem root and fail. Build

View File

@@ -7,6 +7,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"source": "./src/index.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },

View File

@@ -11,11 +11,13 @@
"exports": { "exports": {
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"import": "./src/index.ts" "source": "./src/index.ts",
"import": "./dist/index.js"
}, },
"./probe": { "./probe": {
"types": "./src/probe.ts", "types": "./src/probe.ts",
"import": "./src/probe.ts" "source": "./src/probe.ts",
"import": "./dist/probe.js"
} }
}, },
"private": true, "private": true,

View File

@@ -11,11 +11,13 @@
"exports": { "exports": {
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"import": "./src/index.ts" "source": "./src/index.ts",
"import": "./dist/index.js"
}, },
"./probe": { "./probe": {
"types": "./src/probe.ts", "types": "./src/probe.ts",
"import": "./src/probe.ts" "source": "./src/probe.ts",
"import": "./dist/probe.js"
} }
}, },
"private": true, "private": true,

View File

@@ -7,6 +7,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"source": "./src/index.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },

View File

@@ -12,6 +12,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"source": "./src/index.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },

View File

@@ -9,6 +9,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"source": "./src/index.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },

View File

@@ -7,15 +7,18 @@
"exports": { "exports": {
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"import": "./src/index.ts" "source": "./src/index.ts",
"import": "./dist/index.js"
}, },
"./server": { "./server": {
"types": "./src/server/index.d.ts", "types": "./src/server/index.d.ts",
"import": "./src/server/index.ts" "source": "./src/server/index.ts",
"import": "./dist/server/index.js"
}, },
"./roadmap-suggestions": { "./roadmap-suggestions": {
"types": "./src/roadmap-suggestions.d.ts", "types": "./src/roadmap-suggestions.d.ts",
"import": "./src/roadmap-suggestions.ts" "source": "./src/roadmap-suggestions.ts",
"import": "./dist/roadmap-suggestions.js"
} }
}, },
"scripts": { "scripts": {

View File

@@ -12,6 +12,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"source": "./src/index.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },