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:
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user