FN-6616: generalize bundled plugin freshness guard
Generalize stale-artifact detection so all staged bundled CLI plugins are checked consistently. - Add reusable bundled plugin freshness helpers and coverage for missing, fresh, and stale artifact cases. - Centralize staged bundled plugin id lists for runtime bundling and staged raw-src plugin checks. - Assert tsup output leaves every staged bundled plugin loadable and document the broader freshness guard. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-6616-bundled-plugin-freshness.md | 5 + docs/PLUGIN_AUTHORING.md | 8 ++ .../__tests__/bundled-plugin-freshness.test.ts | 75 +++++++++++++ .../cli/src/plugins/bundled-plugin-freshness.ts | 118 +++++++++++++++++++++ .../cli/src/plugins/staged-bundled-plugin-ids.ts | 22 ++++ packages/cli/tsup.config.ts | 41 ++++--- 6 files changed, 257 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-6616 Fusion-Task-Lineage: 3804827b-8f64-4268-a7bb-31a4b4ca9a87
This commit is contained in:
5
.changeset/fn-6616-bundled-plugin-freshness.md
Normal file
5
.changeset/fn-6616-bundled-plugin-freshness.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Generalize bundled plugin freshness checks across staged CLI plugin artifacts.
|
||||||
@@ -767,6 +767,14 @@ Bundled workspace plugin pattern:
|
|||||||
- Register the lazy dashboard component in host code (currently `packages/dashboard/app/plugins/registerBundledPluginViews.ts`)
|
- Register the lazy dashboard component in host code (currently `packages/dashboard/app/plugins/registerBundledPluginViews.ts`)
|
||||||
- CLI bundling inlines backend plugin code from workspace packages; dashboard view modules are imported by the dashboard build via the host registry
|
- CLI bundling inlines backend plugin code from workspace packages; dashboard view modules are imported by the dashboard build via the host registry
|
||||||
|
|
||||||
|
### Bundled plugin build-freshness guard
|
||||||
|
|
||||||
|
<!-- FNXC:BundledPlugins 2026-06-17-22:31: Bundled plugins can load gitignored compiled artifacts before source during workspace/dev resolution, so plugin authors need a documented recovery path when the generic freshness guard detects stale dist output. -->
|
||||||
|
|
||||||
|
Bundled plugins shipped in `@runfusion/fusion` are tracked by the staged bundled-plugin set in `packages/cli/src/plugins/staged-bundled-plugin-ids.ts`; the default auto-install subset remains `BUNDLED_PLUGIN_IDS` in `packages/cli/src/plugins/bundled-plugin-install.ts`. The CLI build asserts every staged bundled plugin has a loadable entry under `packages/cli/dist/plugins/<id>/`, and the freshness test checks any per-plugin `plugins/<id>/dist/index.js` that exists against the newest `src/**` mtime.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
Runtime host context contract:
|
Runtime host context contract:
|
||||||
- Registered views receive a `context` object from the dashboard host (`PluginDashboardViewContext`).
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { BUNDLED_PLUGIN_IDS } from "../bundled-plugin-install.js";
|
||||||
|
import { findStaleBundledPlugins } from "../bundled-plugin-freshness.js";
|
||||||
|
import { ALL_STAGED_BUNDLED_IDS } from "../staged-bundled-plugin-ids.js";
|
||||||
|
|
||||||
|
const older = new Date("2026-01-01T00:00:00.000Z");
|
||||||
|
const newer = new Date("2026-01-01T00:01:00.000Z");
|
||||||
|
|
||||||
|
describe("bundled plugin build freshness", () => {
|
||||||
|
let tempRoot: string | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (tempRoot) {
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
tempRoot = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTempPluginsRoot(): string {
|
||||||
|
tempRoot = mkdtempSync(join(tmpdir(), "bundled-plugin-freshness-"));
|
||||||
|
return tempRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePluginFile(pluginsRoot: string, pluginId: string, relativePath: string, content = "// test fixture\n") {
|
||||||
|
const fullPath = join(pluginsRoot, pluginId, relativePath);
|
||||||
|
mkdirSync(dirname(fullPath), { recursive: true });
|
||||||
|
writeFileSync(fullPath, content);
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports stale compiled dist while allowing fresh and dist-absent plugins", () => {
|
||||||
|
const pluginsRoot = makeTempPluginsRoot();
|
||||||
|
|
||||||
|
const staleSrc = writePluginFile(pluginsRoot, "fixture-stale", "src/index.ts");
|
||||||
|
const staleDist = writePluginFile(pluginsRoot, "fixture-stale", "dist/index.js");
|
||||||
|
utimesSync(staleDist, older, older);
|
||||||
|
utimesSync(staleSrc, newer, newer);
|
||||||
|
|
||||||
|
const freshSrc = writePluginFile(pluginsRoot, "fixture-fresh", "src/index.ts");
|
||||||
|
const freshDist = writePluginFile(pluginsRoot, "fixture-fresh", "dist/index.js");
|
||||||
|
utimesSync(freshSrc, older, older);
|
||||||
|
utimesSync(freshDist, newer, newer);
|
||||||
|
|
||||||
|
writePluginFile(pluginsRoot, "fixture-dist-absent", "src/index.ts");
|
||||||
|
|
||||||
|
const stale = findStaleBundledPlugins(["fixture-stale", "fixture-fresh", "fixture-dist-absent"], {
|
||||||
|
pluginsRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stale).toHaveLength(1);
|
||||||
|
expect(stale[0]).toMatchObject({ id: "fixture-stale" });
|
||||||
|
expect(stale[0]?.reason).toContain("run pnpm build");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the live staged bundled-plugin set fresh after build", () => {
|
||||||
|
expect(findStaleBundledPlugins(ALL_STAGED_BUNDLED_IDS)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the auto-install list covered by the staged bundled-plugin set", () => {
|
||||||
|
const staged = new Set<string>(ALL_STAGED_BUNDLED_IDS);
|
||||||
|
const missingFromStagedSet = BUNDLED_PLUGIN_IDS.filter((id) => !staged.has(id));
|
||||||
|
|
||||||
|
expect(missingFromStagedSet).toEqual([]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:BundledPlugins 2026-06-17-22:06:
|
||||||
|
* The staged set intentionally remains a superset today: droid/acp runtimes are shipped for explicit runtime selection but are not part of the default auto-install list. Use subset coverage, not equality, until product requirements say those runtimes should auto-install.
|
||||||
|
*/
|
||||||
|
expect(new Set(BUNDLED_PLUGIN_IDS)).not.toEqual(staged);
|
||||||
|
expect(ALL_STAGED_BUNDLED_IDS).toEqual(expect.arrayContaining(["fusion-plugin-droid-runtime", "fusion-plugin-acp-runtime"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
118
packages/cli/src/plugins/bundled-plugin-freshness.ts
Normal file
118
packages/cli/src/plugins/bundled-plugin-freshness.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export type StaleBundledPlugin = {
|
||||||
|
id: string;
|
||||||
|
pluginDir: string;
|
||||||
|
reason: string;
|
||||||
|
newestSrcMtimeMs: number;
|
||||||
|
oldestDistMtimeMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BundledPluginFreshnessOptions = {
|
||||||
|
pluginsRoot?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IGNORED_DIR_NAMES = new Set(["node_modules", "__tests__", ".git"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:BundledPlugins 2026-06-17-21:50:
|
||||||
|
* Bundled plugin loaders resolve compiled entries before source entries in shipped installs, and prior work showed gitignored compiled output can drift from src and ship stale runtime behavior. Keep this guard generic and mtime-based so every staged bundled plugin gets the same stale-artifact protection that FN-6596 added for Compound Engineering after ce-debug regressed.
|
||||||
|
*/
|
||||||
|
export function findStaleBundledPlugins(
|
||||||
|
pluginIds: readonly string[],
|
||||||
|
opts: BundledPluginFreshnessOptions = {},
|
||||||
|
): StaleBundledPlugin[] {
|
||||||
|
const pluginsRoot = opts.pluginsRoot ?? defaultPluginsRoot();
|
||||||
|
const stalePlugins: StaleBundledPlugin[] = [];
|
||||||
|
|
||||||
|
for (const id of pluginIds) {
|
||||||
|
const pluginDir = join(pluginsRoot, id);
|
||||||
|
const srcDir = join(pluginDir, "src");
|
||||||
|
const distDir = join(pluginDir, "dist");
|
||||||
|
const distIndexPath = join(distDir, "index.js");
|
||||||
|
|
||||||
|
if (!existsSync(distIndexPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestSrcMtimeMs = newestFileMtimeMs(srcDir);
|
||||||
|
const oldestDistMtimeMs = oldestCompiledDistMtimeMs(distDir);
|
||||||
|
|
||||||
|
if (newestSrcMtimeMs === null || oldestDistMtimeMs === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newestSrcMtimeMs > oldestDistMtimeMs) {
|
||||||
|
stalePlugins.push({
|
||||||
|
id,
|
||||||
|
pluginDir,
|
||||||
|
newestSrcMtimeMs,
|
||||||
|
oldestDistMtimeMs,
|
||||||
|
reason: `${id} dist is stale relative to src — run pnpm build`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stalePlugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBundledPluginsFresh(
|
||||||
|
pluginIds: readonly string[],
|
||||||
|
opts: BundledPluginFreshnessOptions = {},
|
||||||
|
): void {
|
||||||
|
const stalePlugins = findStaleBundledPlugins(pluginIds, opts);
|
||||||
|
if (stalePlugins.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = stalePlugins.map((plugin) => `- ${plugin.reason} (${plugin.pluginDir})`).join("\n");
|
||||||
|
throw new Error(`Stale bundled plugin compiled artifacts detected:\n${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPluginsRoot(): string {
|
||||||
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
return resolve(moduleDir, "..", "..", "..", "..", "plugins");
|
||||||
|
}
|
||||||
|
|
||||||
|
function newestFileMtimeMs(rootDir: string): number | null {
|
||||||
|
let newest: number | null = null;
|
||||||
|
walkFiles(rootDir, (path) => {
|
||||||
|
const mtimeMs = statSync(path).mtimeMs;
|
||||||
|
newest = newest === null ? mtimeMs : Math.max(newest, mtimeMs);
|
||||||
|
});
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oldestCompiledDistMtimeMs(rootDir: string): number | null {
|
||||||
|
let oldest: number | null = null;
|
||||||
|
walkFiles(rootDir, (path) => {
|
||||||
|
if (path.endsWith(".map")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mtimeMs = statSync(path).mtimeMs;
|
||||||
|
oldest = oldest === null ? mtimeMs : Math.min(oldest, mtimeMs);
|
||||||
|
});
|
||||||
|
return oldest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkFiles(rootDir: string, visitFile: (path: string) => void): void {
|
||||||
|
if (!existsSync(rootDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = readdirSync(rootDir, { withFileTypes: true, encoding: "utf8" });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(rootDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (!IGNORED_DIR_NAMES.has(entry.name)) {
|
||||||
|
walkFiles(entryPath, visitFile);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isFile()) {
|
||||||
|
visitFile(entryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/cli/src/plugins/staged-bundled-plugin-ids.ts
Normal file
22
packages/cli/src/plugins/staged-bundled-plugin-ids.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const RUNTIME_PLUGIN_IDS = [
|
||||||
|
"fusion-plugin-hermes-runtime",
|
||||||
|
"fusion-plugin-openclaw-runtime",
|
||||||
|
"fusion-plugin-paperclip-runtime",
|
||||||
|
"fusion-plugin-cursor-runtime",
|
||||||
|
"fusion-plugin-droid-runtime",
|
||||||
|
"fusion-plugin-acp-runtime",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:BundledPlugins 2026-06-17-22:03:
|
||||||
|
* The published CLI stages more plugins than the auto-install subset: droid and ACP runtimes are bundled for explicit runtime use but are not auto-installed with the default plugin set. Keep one staged-id source shared by build assertions and freshness tests so a newly shipped plugin cannot bypass stale-dist checks.
|
||||||
|
*/
|
||||||
|
export const ALL_STAGED_BUNDLED_IDS = [
|
||||||
|
...RUNTIME_PLUGIN_IDS,
|
||||||
|
"fusion-plugin-dependency-graph",
|
||||||
|
"fusion-plugin-roadmap",
|
||||||
|
"fusion-plugin-compound-engineering",
|
||||||
|
"fusion-plugin-whatsapp-chat",
|
||||||
|
"fusion-plugin-reports",
|
||||||
|
"fusion-plugin-cli-printing-press",
|
||||||
|
] as const;
|
||||||
@@ -3,19 +3,9 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } fr
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { build as esbuildBuild } from "esbuild";
|
import { build as esbuildBuild } from "esbuild";
|
||||||
|
import { ALL_STAGED_BUNDLED_IDS, RUNTIME_PLUGIN_IDS } from "./src/plugins/staged-bundled-plugin-ids";
|
||||||
|
|
||||||
// Runtime plugin ids that ship inside the published CLI tarball. Each plugin's
|
export { ALL_STAGED_BUNDLED_IDS };
|
||||||
// entry is esbuild-bundled into dist/plugins/<id>/bundled.js with workspace
|
|
||||||
// deps (@fusion/plugin-sdk) inlined, since npm publish strips node_modules
|
|
||||||
// directories. See ensureBundledPluginInstalled for the loader-side counterpart.
|
|
||||||
const RUNTIME_PLUGIN_IDS = [
|
|
||||||
"fusion-plugin-hermes-runtime",
|
|
||||||
"fusion-plugin-openclaw-runtime",
|
|
||||||
"fusion-plugin-paperclip-runtime",
|
|
||||||
"fusion-plugin-cursor-runtime",
|
|
||||||
"fusion-plugin-droid-runtime",
|
|
||||||
"fusion-plugin-acp-runtime",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([
|
const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([
|
||||||
"fusion-plugin-openclaw-runtime",
|
"fusion-plugin-openclaw-runtime",
|
||||||
@@ -129,6 +119,27 @@ async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = fal
|
|||||||
console.log(`Bundled plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`);
|
console.log(`Bundled plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertAllStagedBundledPluginsLoadable() {
|
||||||
|
const missingEntries: string[] = [];
|
||||||
|
|
||||||
|
for (const pluginId of ALL_STAGED_BUNDLED_IDS) {
|
||||||
|
const destDir = join(__dirname, "dist", "plugins", pluginId);
|
||||||
|
const manifestPath = join(destDir, "manifest.json");
|
||||||
|
const bundledEntryPath = join(destDir, "bundled.js");
|
||||||
|
const sourceEntryPath = join(destDir, "src", "index.ts");
|
||||||
|
|
||||||
|
if (!existsSync(manifestPath) || (!existsSync(bundledEntryPath) && !existsSync(sourceEntryPath))) {
|
||||||
|
missingEntries.push(
|
||||||
|
`${pluginId} (expected manifest.json plus bundled.js or src/index.ts under ${destDir})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingEntries.length > 0) {
|
||||||
|
throw new Error(`[tsup] Missing loadable staged bundled plugin entries:\n${missingEntries.join("\n")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pluginSdkEntry = join(__dirname, "..", "plugin-sdk", "src", "index.ts");
|
const pluginSdkEntry = join(__dirname, "..", "plugin-sdk", "src", "index.ts");
|
||||||
const pluginSdkCoreRuntimeShim = join(__dirname, "src", "plugin-sdk-core-runtime-shim.ts");
|
const pluginSdkCoreRuntimeShim = join(__dirname, "src", "plugin-sdk-core-runtime-shim.ts");
|
||||||
|
|
||||||
@@ -291,6 +302,12 @@ const cliBuildConfig = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:BundledPlugins 2026-06-17-22:15:
|
||||||
|
* Build output must cover the complete staged plugin surface, including raw-src copied plugins that do not pass through bundlePluginEntry's per-plugin bundled.js assertion. Droid and ACP runtimes are intentionally staged but not auto-installed pending FN-6623, so this checks loadable staged entries rather than BUNDLED_PLUGIN_IDS equality.
|
||||||
|
*/
|
||||||
|
assertAllStagedBundledPluginsLoadable();
|
||||||
|
|
||||||
if (existsSync(dashboardClientDest)) {
|
if (existsSync(dashboardClientDest)) {
|
||||||
rmSync(dashboardClientDest, { recursive: true, force: true });
|
rmSync(dashboardClientDest, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user