feat(FN-3299): auto-install bundled runtime plugins on first save
Hermes / OpenClaw / Paperclip runtime cards in Settings now lazily register themselves on first Save instead of failing with `Plugin "fusion-plugin-...-runtime" not found`. The CLI also bundles each runtime plugin (with @fusion/plugin-sdk inlined via esbuild) into dist/plugins/<id>/bundled.js so npm/npx-installed Fusion can load them without the workspace SDK dependency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fn-3299-runtime-plugin-auto-install.md
Normal file
5
.changeset/fn-3299-runtime-plugin-auto-install.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Auto-install bundled runtime plugins (Hermes / OpenClaw / Paperclip) on first Save in Settings, and ship them inside the published CLI so npx-installed Fusion can load them. Previously the runtime cards rendered but `Save` / `Save and Test` failed with `Plugin "fusion-plugin-…-runtime" not found`, and the plugins were unavailable when the CLI was installed via npm/npx because their workspace `@fusion/plugin-sdk` dependency wasn't bundled. Each runtime plugin is now bundled at CLI build time into a self-contained `dist/plugins/<id>/bundled.js`, and `PUT /api/plugins/:id/settings` lazily registers a bundled runtime via the new `ServerOptions.ensureBundledPluginInstalled` hook the first time the user saves.
|
||||||
@@ -93,6 +93,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@vitest/coverage-v8": "^3.1.0",
|
"@vitest/coverage-v8": "^3.1.0",
|
||||||
"cross-env": "^7.0.0",
|
"cross-env": "^7.0.0",
|
||||||
|
"esbuild": "^0.25.12",
|
||||||
"ink-testing-library": "^4.0.0",
|
"ink-testing-library": "^4.0.0",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ import {
|
|||||||
import { resolveSelfExtension } from "./self-extension.js";
|
import { resolveSelfExtension } from "./self-extension.js";
|
||||||
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
|
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
|
||||||
import { syncStartupModels } from "./startup-model-sync.js";
|
import { syncStartupModels } from "./startup-model-sync.js";
|
||||||
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
|
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
|
||||||
|
|
||||||
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -455,6 +455,30 @@ export async function runServe(
|
|||||||
console.warn(`[plugins] Failed to auto-install bundled Dependency Graph plugin: ${err instanceof Error ? err.message : err}`);
|
console.warn(`[plugins] Failed to auto-install bundled Dependency Graph plugin: ${err instanceof Error ? err.message : err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazy-install hook for bundled runtime plugins (Hermes/OpenClaw/Paperclip).
|
||||||
|
const ensureBundledPluginInstalledCallback = async (pluginId: string): Promise<boolean> => {
|
||||||
|
if (!isBundledPluginId(pluginId)) {
|
||||||
|
console.warn(`[plugins] ensureBundledPluginInstalled: unknown bundled plugin id "${pluginId}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const status = await ensureBundledPluginInstalled(pluginStore, pluginLoader, pluginId);
|
||||||
|
if (status === "missing-bundle") {
|
||||||
|
console.warn(`[plugins] Bundled plugin "${pluginId}" was not found in this build`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status === "installed") {
|
||||||
|
console.log(`[plugins] Installed bundled plugin "${pluginId}"`);
|
||||||
|
} else if (status === "updated") {
|
||||||
|
console.log(`[plugins] Updated bundled plugin "${pluginId}"`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[plugins] Failed to auto-install bundled plugin "${pluginId}": ${err instanceof Error ? err.message : err}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
|
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
|
||||||
// can discover installed runtimes like Hermes and OpenClaw.
|
// can discover installed runtimes like Hermes and OpenClaw.
|
||||||
try {
|
try {
|
||||||
@@ -709,6 +733,7 @@ export async function runServe(
|
|||||||
pluginStore,
|
pluginStore,
|
||||||
pluginLoader,
|
pluginLoader,
|
||||||
pluginRunner: pluginLoader,
|
pluginRunner: pluginLoader,
|
||||||
|
ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback,
|
||||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||||
onProjectRegistered: ({ path }) => {
|
onProjectRegistered: ({ path }) => {
|
||||||
// Fire-and-forget: install the fusion Claude-skill when pi-claude-cli
|
// Fire-and-forget: install the fusion Claude-skill when pi-claude-cli
|
||||||
|
|||||||
@@ -6,14 +6,33 @@ import { validatePluginManifest, type PluginInstallation, type PluginLoader, typ
|
|||||||
|
|
||||||
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
|
||||||
|
|
||||||
function getCandidatePluginPaths(): string[] {
|
export const BUNDLED_PLUGIN_IDS = [
|
||||||
|
"fusion-plugin-dependency-graph",
|
||||||
|
"fusion-plugin-hermes-runtime",
|
||||||
|
"fusion-plugin-openclaw-runtime",
|
||||||
|
"fusion-plugin-paperclip-runtime",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
|
||||||
|
|
||||||
|
export function isBundledPluginId(id: string): id is BundledPluginId {
|
||||||
|
return (BUNDLED_PLUGIN_IDS as readonly string[]).includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnsureBundledResult =
|
||||||
|
| "installed"
|
||||||
|
| "updated"
|
||||||
|
| "already-installed"
|
||||||
|
| "missing-bundle";
|
||||||
|
|
||||||
|
function getCandidatePluginDirs(pluginId: string): string[] {
|
||||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const cliPackageRoot = resolve(moduleDir, "..", "..");
|
const cliPackageRoot = resolve(moduleDir, "..", "..");
|
||||||
|
|
||||||
return [
|
return [
|
||||||
join(cliPackageRoot, "dist", "plugins", DEPENDENCY_GRAPH_PLUGIN_ID),
|
join(cliPackageRoot, "dist", "plugins", pluginId),
|
||||||
join(cliPackageRoot, "plugins", DEPENDENCY_GRAPH_PLUGIN_ID),
|
join(cliPackageRoot, "plugins", pluginId),
|
||||||
join(cliPackageRoot, "..", "..", "plugins", DEPENDENCY_GRAPH_PLUGIN_ID),
|
join(cliPackageRoot, "..", "..", "plugins", pluginId),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +47,8 @@ async function loadManifest(pluginDir: string): Promise<PluginManifest> {
|
|||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBundledDependencyGraphPath(): string | null {
|
function resolveBundledPluginDir(pluginId: string): string | null {
|
||||||
for (const path of getCandidatePluginPaths()) {
|
for (const path of getCandidatePluginDirs(pluginId)) {
|
||||||
if (existsSync(join(path, "manifest.json"))) {
|
if (existsSync(join(path, "manifest.json"))) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
@@ -37,56 +56,103 @@ function resolveBundledDependencyGraphPath(): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureBundledDependencyGraphPluginInstalled(
|
/**
|
||||||
|
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
|
||||||
|
* does not allow directory imports, so we must register the explicit file the
|
||||||
|
* loader will dynamic-import. Preference order:
|
||||||
|
* 1. ./bundled.js (esbuild-bundled, ships in npm tarball)
|
||||||
|
* 2. ./dist/index.js
|
||||||
|
* 3. ./src/index.ts (workspace dev)
|
||||||
|
* 4. fall back to the directory itself
|
||||||
|
*/
|
||||||
|
export function resolvePluginEntryPath(pluginDir: string): string {
|
||||||
|
const candidates = [
|
||||||
|
join(pluginDir, "bundled.js"),
|
||||||
|
join(pluginDir, "dist", "index.js"),
|
||||||
|
join(pluginDir, "src", "index.ts"),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pluginDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureBundledPluginInstalled(
|
||||||
pluginStore: PluginStore,
|
pluginStore: PluginStore,
|
||||||
pluginLoader: PluginLoader,
|
pluginLoader: PluginLoader,
|
||||||
): Promise<"installed" | "updated" | "already-installed" | "missing-bundle"> {
|
pluginId: string,
|
||||||
|
): Promise<EnsureBundledResult> {
|
||||||
let existingPlugin: PluginInstallation | null = null;
|
let existingPlugin: PluginInstallation | null = null;
|
||||||
try {
|
try {
|
||||||
existingPlugin = await pluginStore.getPlugin(DEPENDENCY_GRAPH_PLUGIN_ID);
|
existingPlugin = await pluginStore.getPlugin(pluginId);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue; plugin not installed yet.
|
// Continue; plugin not installed yet.
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundledPath = resolveBundledDependencyGraphPath();
|
const bundledDir = resolveBundledPluginDir(pluginId);
|
||||||
if (!bundledPath) {
|
if (!bundledDir) {
|
||||||
return "missing-bundle";
|
return "missing-bundle";
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = await loadManifest(bundledPath);
|
const manifest = await loadManifest(bundledDir);
|
||||||
|
const entryPath = resolvePluginEntryPath(bundledDir);
|
||||||
|
|
||||||
if (existingPlugin) {
|
if (existingPlugin) {
|
||||||
// Check if stored path or version is stale compared to the bundled copy
|
const pathChanged = existingPlugin.path !== entryPath;
|
||||||
const pathChanged = existingPlugin.path !== bundledPath;
|
|
||||||
const versionChanged = existingPlugin.version !== manifest.version;
|
const versionChanged = existingPlugin.version !== manifest.version;
|
||||||
|
|
||||||
if (!pathChanged && !versionChanged) {
|
if (!pathChanged && !versionChanged) {
|
||||||
|
if (existingPlugin.enabled) {
|
||||||
|
try {
|
||||||
|
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
return "already-installed";
|
return "already-installed";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the stored record to match the current bundled copy
|
await pluginStore.updatePlugin(pluginId, {
|
||||||
await pluginStore.updatePlugin(DEPENDENCY_GRAPH_PLUGIN_ID, {
|
...(pathChanged ? { path: entryPath } : {}),
|
||||||
...(pathChanged ? { path: bundledPath } : {}),
|
|
||||||
...(versionChanged ? { version: manifest.version } : {}),
|
...(versionChanged ? { version: manifest.version } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// If the plugin is enabled, load it so it picks up the new path/version
|
|
||||||
if (existingPlugin.enabled) {
|
if (existingPlugin.enabled) {
|
||||||
await pluginLoader.loadPlugin(existingPlugin.id);
|
try {
|
||||||
|
await pluginLoader.loadPlugin(existingPlugin.id);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "updated";
|
return "updated";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh install
|
|
||||||
const plugin = await pluginStore.registerPlugin({
|
const plugin = await pluginStore.registerPlugin({
|
||||||
manifest,
|
manifest,
|
||||||
path: bundledPath,
|
path: entryPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (plugin.enabled) {
|
if (plugin.enabled) {
|
||||||
await pluginLoader.loadPlugin(plugin.id);
|
try {
|
||||||
|
await pluginLoader.loadPlugin(plugin.id);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "installed";
|
return "installed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use {@link ensureBundledPluginInstalled} with the explicit plugin id.
|
||||||
|
* Kept for backwards compatibility with existing call sites.
|
||||||
|
*/
|
||||||
|
export async function ensureBundledDependencyGraphPluginInstalled(
|
||||||
|
pluginStore: PluginStore,
|
||||||
|
pluginLoader: PluginLoader,
|
||||||
|
): Promise<EnsureBundledResult> {
|
||||||
|
return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { defineConfig } from "tsup";
|
import { defineConfig } from "tsup";
|
||||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
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";
|
||||||
|
|
||||||
|
// Runtime plugin ids that ship inside the published CLI tarball. Each plugin's
|
||||||
|
// 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",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const dashboardClientSrc = join(__dirname, "..", "dashboard", "dist", "client");
|
const dashboardClientSrc = join(__dirname, "..", "dashboard", "dist", "client");
|
||||||
@@ -128,6 +139,66 @@ export default defineConfig({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bundle each runtime plugin into a self-contained ESM file so npm/npx
|
||||||
|
// installs can load them without the workspace `@fusion/plugin-sdk`.
|
||||||
|
for (const pluginId of RUNTIME_PLUGIN_IDS) {
|
||||||
|
const pluginSrcDir = join(__dirname, "..", "..", "plugins", pluginId);
|
||||||
|
const pluginDestDir = join(__dirname, "dist", "plugins", pluginId);
|
||||||
|
|
||||||
|
if (existsSync(pluginDestDir)) {
|
||||||
|
rmSync(pluginDestDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
if (!existsSync(pluginSrcDir)) {
|
||||||
|
console.warn(
|
||||||
|
`WARNING: Runtime plugin source not found at ${pluginSrcDir}; ${pluginId} will be unavailable in the published package.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(pluginDestDir, { recursive: true });
|
||||||
|
|
||||||
|
cpSync(join(pluginSrcDir, "manifest.json"), join(pluginDestDir, "manifest.json"));
|
||||||
|
|
||||||
|
// Stripped package.json: no dependencies (workspace SDK is inlined into
|
||||||
|
// bundled.js), exports point at the bundle. Keeps the published tarball
|
||||||
|
// self-contained without leaking workspace-only metadata.
|
||||||
|
const srcPkg = JSON.parse(readFileSync(join(pluginSrcDir, "package.json"), "utf-8"));
|
||||||
|
const destPkg = {
|
||||||
|
name: srcPkg.name,
|
||||||
|
version: srcPkg.version,
|
||||||
|
type: "module",
|
||||||
|
exports: { ".": { import: "./bundled.js" } },
|
||||||
|
private: true,
|
||||||
|
};
|
||||||
|
writeFileSync(join(pluginDestDir, "package.json"), JSON.stringify(destPkg, null, 2));
|
||||||
|
|
||||||
|
// Pick the best available entry: built dist/index.js if present, else
|
||||||
|
// raw src/index.ts (esbuild can transpile TS).
|
||||||
|
const builtEntry = join(pluginSrcDir, "dist", "index.js");
|
||||||
|
const srcEntry = join(pluginSrcDir, "src", "index.ts");
|
||||||
|
const entry = existsSync(builtEntry) ? builtEntry : srcEntry;
|
||||||
|
|
||||||
|
if (!existsSync(entry)) {
|
||||||
|
console.warn(`WARNING: No entry found for ${pluginId} (looked for dist/index.js and src/index.ts)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await esbuildBuild({
|
||||||
|
entryPoints: [entry],
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "node",
|
||||||
|
target: "node22",
|
||||||
|
outfile: join(pluginDestDir, "bundled.js"),
|
||||||
|
// @fusion/core and @fusion/engine are loaded by the host process at
|
||||||
|
// runtime; the SDK is inlined.
|
||||||
|
external: ["@fusion/core", "@fusion/engine"],
|
||||||
|
logLevel: "warning",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Bundled runtime plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`);
|
||||||
|
}
|
||||||
|
|
||||||
if (existsSync(dashboardClientDest)) {
|
if (existsSync(dashboardClientDest)) {
|
||||||
rmSync(dashboardClientDest, { recursive: true, force: true });
|
rmSync(dashboardClientDest, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -827,6 +827,106 @@ describe("PUT /plugins/:id/settings", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PUT /plugins/:id/settings auto-install for bundled runtime plugins", () => {
|
||||||
|
let store: TaskStore;
|
||||||
|
let pluginStore: PluginStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pluginStore = createMockPluginStore();
|
||||||
|
store = createMockTaskStore({
|
||||||
|
getPluginStore: vi.fn().mockReturnValue(pluginStore),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildAppWithBundleHook(
|
||||||
|
ensureBundledPluginInstalled: (id: string) => Promise<boolean>,
|
||||||
|
) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(
|
||||||
|
"/api",
|
||||||
|
createApiRoutes(store, {
|
||||||
|
pluginStore,
|
||||||
|
pluginLoader: createMockPluginLoader(),
|
||||||
|
ensureBundledPluginInstalled,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("auto-installs a bundled runtime plugin on first save", async () => {
|
||||||
|
// Plugin not yet registered → first getPlugin throws.
|
||||||
|
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new Error("Plugin not found"),
|
||||||
|
);
|
||||||
|
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...FAKE_PLUGIN,
|
||||||
|
id: "fusion-plugin-hermes-runtime",
|
||||||
|
settings: { apiKey: "k" },
|
||||||
|
});
|
||||||
|
const ensure = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildAppWithBundleHook(ensure),
|
||||||
|
"PUT",
|
||||||
|
"/api/plugins/fusion-plugin-hermes-runtime/settings",
|
||||||
|
{ settings: { apiKey: "k" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(ensure).toHaveBeenCalledWith("fusion-plugin-hermes-runtime");
|
||||||
|
expect(pluginStore.updatePluginSettings).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips auto-install when the plugin is already registered", async () => {
|
||||||
|
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
|
||||||
|
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
|
||||||
|
const ensure = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildAppWithBundleHook(ensure),
|
||||||
|
"PUT",
|
||||||
|
"/api/plugins/fusion-plugin-hermes-runtime/settings",
|
||||||
|
{ settings: { apiKey: "k" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(ensure).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invoke auto-install for non-bundled plugin ids", async () => {
|
||||||
|
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
|
||||||
|
const ensure = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildAppWithBundleHook(ensure),
|
||||||
|
"PUT",
|
||||||
|
"/api/plugins/some-third-party-plugin/settings",
|
||||||
|
{ settings: { apiKey: "k" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(ensure).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when auto-install throws", async () => {
|
||||||
|
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new Error("Plugin not found"),
|
||||||
|
);
|
||||||
|
const ensure = vi.fn().mockRejectedValue(new Error("bundle missing"));
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildAppWithBundleHook(ensure),
|
||||||
|
"PUT",
|
||||||
|
"/api/plugins/fusion-plugin-hermes-runtime/settings",
|
||||||
|
{ settings: { apiKey: "k" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toContain("Failed to auto-install");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("DELETE /plugins/:id", () => {
|
describe("DELETE /plugins/:id", () => {
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
let pluginStore: PluginStore;
|
let pluginStore: PluginStore;
|
||||||
|
|||||||
@@ -3470,6 +3470,30 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
throw badRequest("Request body must have a 'settings' object");
|
throw badRequest("Request body must have a 'settings' object");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-install bundled runtime plugins (Hermes/OpenClaw/Paperclip) on
|
||||||
|
// first save. The Settings UI surfaces these as fallback cards before
|
||||||
|
// they're actually registered, so the first PUT must lazily install them
|
||||||
|
// rather than 404. The host (CLI) injects ensureBundledPluginInstalled
|
||||||
|
// because dashboard doesn't know the on-disk bundle layout.
|
||||||
|
const isBundledFallback = BUNDLED_PLUGIN_RUNTIMES.some((r) => r.pluginId === id);
|
||||||
|
if (isBundledFallback && options?.ensureBundledPluginInstalled) {
|
||||||
|
let alreadyRegistered = true;
|
||||||
|
try {
|
||||||
|
await pluginStore.getPlugin(id);
|
||||||
|
} catch {
|
||||||
|
alreadyRegistered = false;
|
||||||
|
}
|
||||||
|
if (!alreadyRegistered) {
|
||||||
|
try {
|
||||||
|
await options.ensureBundledPluginInstalled(id);
|
||||||
|
} catch (installErr) {
|
||||||
|
throw internalError(
|
||||||
|
`Failed to auto-install bundled plugin "${id}": ${installErr instanceof Error ? installErr.message : String(installErr)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const plugin = await pluginStore.updatePluginSettings(id, settings);
|
const plugin = await pluginStore.updatePluginSettings(id, settings);
|
||||||
res.json(plugin);
|
res.json(plugin);
|
||||||
|
|||||||
@@ -258,6 +258,16 @@ export interface ServerOptions {
|
|||||||
* settings PUT to fail.
|
* settings PUT to fail.
|
||||||
*/
|
*/
|
||||||
onUseClaudeCliToggled?: (prev: boolean, next: boolean) => void;
|
onUseClaudeCliToggled?: (prev: boolean, next: boolean) => void;
|
||||||
|
/**
|
||||||
|
* Lazily install a bundled runtime plugin (e.g. Hermes/OpenClaw/Paperclip
|
||||||
|
* runtimes) the first time the user clicks Save in Settings. The dashboard
|
||||||
|
* has no knowledge of the on-disk bundle layout, so the host (CLI) injects
|
||||||
|
* this hook. Returns true if the plugin is now registered (either freshly
|
||||||
|
* installed or already present), false if the bundle could not be resolved
|
||||||
|
* (e.g. plugin id is unknown) so the route can fall through to its standard
|
||||||
|
* "plugin not found" error.
|
||||||
|
*/
|
||||||
|
ensureBundledPluginInstalled?: (pluginId: string) => Promise<boolean>;
|
||||||
/**
|
/**
|
||||||
* Returns the host's last-observed resolution of the bundled
|
* Returns the host's last-observed resolution of the bundled
|
||||||
* `@fusion/pi-claude-cli` extension. Populated by serve/daemon/dashboard
|
* `@fusion/pi-claude-cli` extension. Populated by serve/daemon/dashboard
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -93,6 +93,9 @@ importers:
|
|||||||
cross-env:
|
cross-env:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.3
|
version: 7.0.3
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.25.12
|
||||||
|
version: 0.25.12
|
||||||
ink-testing-library:
|
ink-testing-library:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0(@types/react@19.2.14)
|
version: 4.0.0(@types/react@19.2.14)
|
||||||
|
|||||||
Reference in New Issue
Block a user