feat(FN-3170): harden plugin bundling contract and fix verification regress

Hardened the roadmap plugin bundling contract with new output-validation tests and tsup config cleanup, then resolved regressions those changes introduced in the bundle and package-config test suites. A small dashboard UI tweak (TaskDetailModal) was also included.

Fusion-Task-Id: FN-3170
This commit is contained in:
Fusion
2026-05-10 06:09:30 -07:00
committed by gsxdsm
parent 44c26e341e
commit 3bbd153819
8 changed files with 60 additions and 17 deletions

View File

@@ -174,6 +174,12 @@ To use it:
Once enabled, `droid-cli` models appear in Fusion model selection.
## Maintainer note: workspace plugins in published CLI bundles
When CLI or dashboard runtime code imports workspace plugin packages (for example `@fusion-plugin-examples/roadmap`), those imports must stay statically analyzable and covered by `packages/cli/tsup.config.ts` `noExternal` rules so plugin runtime code is inlined into `dist/bin.js`.
Do not introduce dynamic or variable module specifiers for workspace plugin runtime paths in the published execution path. If a workspace plugin is needed for bundled auto-install, stage a bundled plugin entry (`dist/plugins/<id>/bundled.js`) rather than copying raw TypeScript source into `dist/`.
## Full documentation
Architecture details, development setup, and contributor info live in the [project README](https://github.com/Runfusion/Fusion#readme).

View File

@@ -21,6 +21,8 @@ if (mode === "prepack") {
delete devDependencies["@fusion/dashboard"];
delete devDependencies["@fusion/engine"];
delete devDependencies["@fusion/pi-claude-cli"];
delete devDependencies["@fusion/pi-llama-cpp"];
delete devDependencies["@fusion-plugin-examples/roadmap"];
pkg.devDependencies = devDependencies;
writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");

View File

@@ -25,6 +25,10 @@ const cursorPluginManifestPath = bundlePath.replace(
"dist/bin.js",
"dist/plugins/fusion-plugin-cursor-runtime/manifest.json",
);
const roadmapPluginBundledPath = bundlePath.replace(
"dist/bin.js",
"dist/plugins/fusion-plugin-roadmap/bundled.js",
);
describe("hasBuiltDashboardAssets", () => {
beforeEach(() => {
@@ -36,6 +40,7 @@ describe("hasBuiltDashboardAssets", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
expect(hasBuiltDashboardAssets()).toBe(false);
});
@@ -44,6 +49,7 @@ describe("hasBuiltDashboardAssets", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
state.existingPaths.add(openclawMcpSchemaServerPath);
expect(hasBuiltDashboardAssets()).toBe(false);
@@ -53,6 +59,7 @@ describe("hasBuiltDashboardAssets", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
state.existingPaths.add(openclawMcpSchemaServerPath);
state.existingPaths.add(droidPluginMcpServerPath);
@@ -63,6 +70,7 @@ describe("hasBuiltDashboardAssets", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
state.existingPaths.add(openclawMcpSchemaServerPath);
state.existingPaths.add(droidPluginMcpServerPath);
state.indexHtml = dashboardClientStubMarker;

View File

@@ -7,6 +7,7 @@ export const workspaceRoot = join(cliRoot, "..", "..");
export const bundlePath = join(cliRoot, "dist", "bin.js");
export const clientIndexPath = join(cliRoot, "dist", "client", "index.html");
const cursorPluginManifestPath = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime", "manifest.json");
const roadmapPluginBundledPath = join(cliRoot, "dist", "plugins", "fusion-plugin-roadmap", "bundled.js");
export const openclawMcpSchemaServerPath = join(
cliRoot,
"dist",
@@ -47,6 +48,7 @@ export function hasBuiltDashboardAssets(): boolean {
!existsSync(bundlePath) ||
!existsSync(clientIndexPath) ||
!existsSync(cursorPluginManifestPath) ||
!existsSync(roadmapPluginBundledPath) ||
!existsSync(openclawMcpSchemaServerPath) ||
!existsSync(droidPluginMcpServerPath)
) {

View File

@@ -37,9 +37,11 @@ describe("CLI bundle output", () => {
expect(content).not.toMatch(/from\s+["']@fusion\/core["']/);
expect(content).not.toMatch(/from\s+["']@fusion\/dashboard["']/);
expect(content).not.toMatch(/from\s+["']@fusion\/engine["']/);
expect(content).not.toMatch(/from\s+["']@fusion-plugin-examples\/roadmap["']/);
expect(content).not.toContain('"@fusion/core"');
expect(content).not.toContain('"@fusion/dashboard"');
expect(content).not.toContain('"@fusion/engine"');
expect(content).not.toContain('"@fusion-plugin-examples/roadmap"');
});
it("does not contain runtime memory-backend side-load imports", () => {
@@ -178,6 +180,26 @@ describe("CLI bundle output", () => {
expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js");
});
it("dist/plugins/fusion-plugin-roadmap/ is staged as bundled runtime output", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-roadmap");
const manifestPath = join(stagedRoot, "manifest.json");
const packageJsonPath = join(stagedRoot, "package.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
expect(manifest.id).toBe("roadmap-planner");
expect(typeof manifest.name).toBe("string");
expect(manifest.name?.length).toBeGreaterThan(0);
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
expect(existsSync(join(stagedRoot, "src"))).toBe(false);
const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
exports?: { "."?: { import?: string } };
};
expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js");
});
it("dist/plugins/fusion-plugin-whatsapp-chat/ is staged with a valid manifest", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-whatsapp-chat");
const manifestPath = join(stagedRoot, "manifest.json");

View File

@@ -22,6 +22,11 @@ function loadRootPackageJson(): any {
return JSON.parse(readFileSync(path, "utf-8"));
}
function loadCliPrepackScript(): string {
const path = join(workspaceRoot, "packages", "cli", "scripts", "prepare-publish-manifest.mjs");
return readFileSync(path, "utf-8");
}
function hasProjectArg(script: string | undefined, project: string): boolean {
const parts = script?.trim().split(/\s+/) ?? [];
return parts.some((part, index) => part === "--project" && parts[index + 1] === project);
@@ -29,6 +34,7 @@ function hasProjectArg(script: string | undefined, project: string): boolean {
describe("CLI package.json publishing config", () => {
const pkg = loadPackageJson("cli");
const prepackScript = loadCliPrepackScript();
it('has "bin" field with fn pointing to ./dist/bin.js', () => {
expect(pkg.bin).toBeDefined();
@@ -85,6 +91,12 @@ describe("CLI package.json publishing config", () => {
expect(deps).toContain("ioredis");
});
it("prepack manifest rewrite strips workspace-only plugin/tooling devDependencies", () => {
expect(prepackScript).toContain('delete devDependencies["@fusion/pi-claude-cli"]');
expect(prepackScript).toContain('delete devDependencies["@fusion/pi-llama-cpp"]');
expect(prepackScript).toContain('delete devDependencies["@fusion-plugin-examples/roadmap"]');
});
// Generalized guard derived from tsup.config.ts. Any non-builtin module
// marked `external` MUST be a runtime dep (so `npm install @runfusion/fusion`
// can resolve it after publish), and any module pulled in via `noExternal`

View File

@@ -132,7 +132,7 @@ export default defineConfig({
esbuildOptions(options) {
options.conditions = [...(options.conditions || []), "source"];
},
noExternal: [/^@fusion\//],
noExternal: [/^@fusion\//, /^@fusion-plugin-examples\//],
// Native module: leave node-pty (aliased to @homebridge fork) out of the
// bundle. esbuild can't statically resolve its conditional native require()s
// (build/Release/pty.node, build/Debug/conpty.node, ...).
@@ -228,20 +228,11 @@ export default defineConfig({
);
}
if (existsSync(roadmapPluginDest)) {
rmSync(roadmapPluginDest, { recursive: true, force: true });
}
if (existsSync(roadmapPluginSrc)) {
mkdirSync(roadmapPluginDest, { recursive: true });
cpSync(join(roadmapPluginSrc, "manifest.json"), join(roadmapPluginDest, "manifest.json"));
cpSync(join(roadmapPluginSrc, "package.json"), join(roadmapPluginDest, "package.json"));
cpSync(join(roadmapPluginSrc, "src"), join(roadmapPluginDest, "src"), { recursive: true });
console.log("Copied roadmap plugin to dist/plugins/fusion-plugin-roadmap/");
} else {
console.warn(
`WARNING: Roadmap plugin source not found at ${roadmapPluginSrc}; bundled auto-install will be unavailable.`,
);
}
await bundlePluginEntry({
pluginId: "fusion-plugin-roadmap",
srcDir: roadmapPluginSrc,
destDir: roadmapPluginDest,
});
if (existsSync(reportsPluginDest)) {
rmSync(reportsPluginDest, { recursive: true, force: true });