diff --git a/.changeset/fn-6616-bundled-plugin-freshness.md b/.changeset/fn-6616-bundled-plugin-freshness.md new file mode 100644 index 0000000000..c4e4cd3de5 --- /dev/null +++ b/.changeset/fn-6616-bundled-plugin-freshness.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Generalize bundled plugin freshness checks across staged CLI plugin artifacts. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 3436b106dd..65cb2c3370 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -767,6 +767,14 @@ Bundled workspace plugin pattern: - 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 +### Bundled plugin build-freshness guard + + + +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//`, and the freshness test checks any per-plugin `plugins//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: - 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. diff --git a/packages/cli/src/plugins/__tests__/bundled-plugin-freshness.test.ts b/packages/cli/src/plugins/__tests__/bundled-plugin-freshness.test.ts new file mode 100644 index 0000000000..9f07e847c2 --- /dev/null +++ b/packages/cli/src/plugins/__tests__/bundled-plugin-freshness.test.ts @@ -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(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"])); + }); +}); diff --git a/packages/cli/src/plugins/bundled-plugin-freshness.ts b/packages/cli/src/plugins/bundled-plugin-freshness.ts new file mode 100644 index 0000000000..e2a167f488 --- /dev/null +++ b/packages/cli/src/plugins/bundled-plugin-freshness.ts @@ -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); + } + } +} diff --git a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts new file mode 100644 index 0000000000..cc9e1c62b9 --- /dev/null +++ b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts @@ -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; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index e26827f681..12b92f2dc6 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -3,19 +3,9 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } fr import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; 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 -// entry is esbuild-bundled into dist/plugins//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; +export { ALL_STAGED_BUNDLED_IDS }; const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([ "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`); } +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 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)) { rmSync(dashboardClientDest, { recursive: true, force: true }); }