FN-5844: add plugin dev loop and external authoring docs

Add a local plugin development loop plus publishable external plugin guidance.

- add `fn plugin dev` routing and implementation with supervised build, install, and hot-reload behavior
- export plugin loader/store helpers and add CLI tests for dev flow, pack shape validation, and scaffold docs coverage
- document external plugin authoring, update CLI/plugin authoring docs, and add a patch changeset for `@runfusion/fusion`

Files changed:
 .changeset/fn-5844-external-plugin-authoring.md    |   5 +
 docs/PLUGIN_AUTHORING.md                           |  10 +-
 docs/cli-reference.md                              |   3 +-
 docs/plugins/external-authoring.md                 | 114 +++++++++++
 packages/cli/src/__tests__/bin.test.ts             |  16 +-
 packages/cli/src/__tests__/plugin-dev.test.ts      | 138 ++++++++++++++
 .../cli/src/__tests__/plugin-pack-shape.test.ts    |  94 +++++++++
 packages/cli/src/__tests__/plugin-scaffold.test.ts |   3 +
 packages/cli/src/bin.ts                            |  16 +-
 packages/cli/src/commands/plugin-dev.ts            | 212 +++++++++++++++++++++
 packages/cli/src/commands/plugin-scaffold.ts       |   6 +-
 packages/cli/src/commands/plugin.ts                |   6 +-
 12 files changed, 613 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-5844
Fusion-Task-Lineage: 80afa0a0-225c-486d-901a-909d14e7b056
This commit is contained in:
gsxdsm
2026-06-01 19:40:14 -07:00
parent b09bbbce9c
commit 0a418e6875
12 changed files with 613 additions and 10 deletions

View File

@@ -105,6 +105,7 @@ const commandMocks = vi.hoisted(() => ({
runPluginRescan: vi.fn(),
runPluginCreate: vi.fn(),
runPluginNew: vi.fn(),
runPluginDev: vi.fn(),
runResearchCreate: vi.fn(),
runResearchList: vi.fn(),
@@ -266,6 +267,10 @@ vi.mock("../commands/plugin-scaffold.js", () => ({
runPluginNew: commandMocks.runPluginNew,
}));
vi.mock("../commands/plugin-dev.js", () => ({
runPluginDev: commandMocks.runPluginDev,
}));
vi.mock("../commands/research.js", () => ({
runResearchCreate: commandMocks.runResearchCreate,
runResearchList: commandMocks.runResearchList,
@@ -551,11 +556,20 @@ describe("bin command routing and fallbacks", () => {
});
});
it("routes plugin dev with once and ai-scan flags", async () => {
await runBin(["plugin", "dev", "./hello-plugin", "--once", "--ai-scan", "-P", "demo"]);
expect(commandMocks.runPluginDev).toHaveBeenCalledWith("./hello-plugin", {
once: true,
aiScan: true,
projectName: "demo",
});
});
it("shows plugin help guidance with install/add alias on unknown plugin subcommand", async () => {
await expect(runBin(["plugin", "oops"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops");
expect(logSpy).toHaveBeenCalledWith(
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new",
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new | dev",
);
});

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const pluginCommandMocks = vi.hoisted(() => {
const store = {
registerPlugin: vi.fn(async () => ({ id: "fusion-plugin-dev-test", enabled: true })),
};
const loader = {
loadPlugin: vi.fn(async () => undefined),
reloadPlugin: vi.fn(async () => undefined),
stopPlugin: vi.fn(async () => undefined),
};
return {
store,
loader,
createPluginStore: vi.fn(async () => store),
createPluginLoader: vi.fn(async () => ({ store, loader })),
resolvePluginEntryFile: vi.fn(async (dir: string) => join(dir, "dist", "index.js")),
loadManifestFromPath: vi.fn(async () => ({
manifest: {
id: "fusion-plugin-dev-test",
name: "Dev Test",
version: "0.1.0",
},
path: "/tmp/fusion-plugin-dev-test",
})),
};
});
vi.mock("../commands/plugin.js", () => ({
createPluginStore: pluginCommandMocks.createPluginStore,
createPluginLoader: pluginCommandMocks.createPluginLoader,
resolvePluginEntryFile: pluginCommandMocks.resolvePluginEntryFile,
loadManifestFromPath: pluginCommandMocks.loadManifestFromPath,
}));
const { runPluginDev } = await import("../commands/plugin-dev.js");
describe("runPluginDev", () => {
let tmpBase: string;
let exitSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tmpBase = join(tmpdir(), `fn-plugin-dev-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpBase, { recursive: true });
mkdirSync(join(tmpBase, "dist"), { recursive: true });
writeFileSync(join(tmpBase, "dist", "index.js"), "export default {};\n");
vi.clearAllMocks();
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
rmSync(tmpBase, { recursive: true, force: true });
exitSpy.mockRestore();
logSpy.mockRestore();
errorSpy.mockRestore();
vi.useRealTimers();
});
it("builds before installing and loads the plugin in once mode", async () => {
const buildFn = vi.fn(async () => undefined);
const watchFn = vi.fn(() => ({ close: vi.fn() }));
await runPluginDev(tmpBase, { once: true, buildFn, watchFn });
expect(buildFn).toHaveBeenCalledOnce();
expect(buildFn).toHaveBeenCalledWith(tmpBase);
expect(pluginCommandMocks.resolvePluginEntryFile).toHaveBeenCalledWith(tmpBase);
expect(pluginCommandMocks.loadManifestFromPath).toHaveBeenCalledWith(tmpBase);
expect(pluginCommandMocks.store.registerPlugin).toHaveBeenCalledWith({
manifest: {
id: "fusion-plugin-dev-test",
name: "Dev Test",
version: "0.1.0",
},
path: join(tmpBase, "dist", "index.js"),
aiScanOnLoad: false,
});
expect(pluginCommandMocks.loader.loadPlugin).toHaveBeenCalledWith("fusion-plugin-dev-test");
expect(pluginCommandMocks.loader.stopPlugin).toHaveBeenCalledWith("fusion-plugin-dev-test");
expect(watchFn).not.toHaveBeenCalled();
});
it("rebuilds and reloads on a watched source change", async () => {
vi.useFakeTimers();
exitSpy.mockImplementation((() => undefined) as typeof process.exit);
const buildFn = vi.fn(async () => undefined);
const close = vi.fn();
let onChange: (() => void) | undefined;
const watchFn = vi.fn((_dir: string, callback: () => void) => {
onChange = callback;
return { close };
});
const devPromise = runPluginDev(tmpBase, { buildFn, watchFn });
await vi.waitFor(() => expect(watchFn).toHaveBeenCalledWith(tmpBase, expect.any(Function)));
expect(onChange).toBeDefined();
onChange?.();
await vi.advanceTimersByTimeAsync(121);
await vi.waitFor(() => expect(pluginCommandMocks.loader.reloadPlugin).toHaveBeenCalledTimes(1));
expect(buildFn).toHaveBeenCalledTimes(2);
expect(pluginCommandMocks.loader.reloadPlugin).toHaveBeenCalledWith("fusion-plugin-dev-test");
process.emit("SIGINT", "SIGINT");
await devPromise;
expect(close).toHaveBeenCalledOnce();
expect(pluginCommandMocks.loader.stopPlugin).toHaveBeenCalledWith("fusion-plugin-dev-test");
});
it("exits non-zero when the plugin path is missing", async () => {
const missingPath = join(tmpBase, "missing");
expect(existsSync(missingPath)).toBe(false);
await expect(runPluginDev(missingPath, { once: true, buildFn: vi.fn() })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(`Plugin path does not exist: ${missingPath}`);
});
it("exits non-zero when manifest loading fails", async () => {
pluginCommandMocks.loadManifestFromPath.mockRejectedValueOnce(new Error("Plugin manifest not found"));
await expect(runPluginDev(tmpBase, { once: true, buildFn: vi.fn(async () => undefined) })).rejects.toThrow(
"process.exit:1",
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Plugin manifest not found"));
});
});

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { extname, join } from "node:path";
import { loadManifestFromPath, resolvePluginEntryFile } from "../commands/plugin.js";
function writePackedPlugin(root: string): void {
mkdirSync(join(root, "dist"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify(
{
name: "fusion-plugin-packed-test",
version: "0.1.0",
type: "module",
exports: {
".": {
types: "./dist/index.d.ts",
import: "./dist/index.js",
},
},
files: ["dist", "manifest.json"],
devDependencies: {
"@runfusion/fusion": "^0.1.0",
},
},
null,
2,
),
);
writeFileSync(
join(root, "manifest.json"),
JSON.stringify(
{
id: "fusion-plugin-packed-test",
name: "Packed Test",
version: "0.1.0",
description: "Synthetic standalone packed plugin artifact.",
},
null,
2,
),
);
writeFileSync(
join(root, "dist", "index.js"),
"import { definePlugin } from '@runfusion/fusion/plugin-sdk';\nexport default definePlugin({ manifest: { id: 'fusion-plugin-packed-test', name: 'Packed Test', version: '0.1.0' } });\n",
);
writeFileSync(join(root, "dist", "index.d.ts"), "export {};\n");
}
function collectTextFiles(root: string): string[] {
const files: string[] = [];
const visit = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
continue;
}
if ([".js", ".mjs", ".cjs", ".json", ".ts", ".d.ts"].includes(extname(fullPath))) {
files.push(fullPath);
}
}
};
visit(root);
return files;
}
describe("standalone plugin pack shape", () => {
it("is accepted by the loader entry seams and does not leak private workspace imports", async () => {
const packedRoot = join(tmpdir(), `fn-plugin-pack-${Date.now()}-${Math.random().toString(36).slice(2)}`);
try {
writePackedPlugin(packedRoot);
const { manifest, path } = await loadManifestFromPath(packedRoot);
expect(path).toBe(packedRoot);
expect(manifest).toMatchObject({
id: "fusion-plugin-packed-test",
name: "Packed Test",
version: "0.1.0",
});
await expect(resolvePluginEntryFile(packedRoot)).resolves.toBe(join(packedRoot, "dist", "index.js"));
const contents = collectTextFiles(packedRoot).map((file) => readFileSync(file, "utf-8"));
expect(contents.length).toBeGreaterThan(0);
for (const content of contents) {
expect(content).not.toContain("@fusion/");
expect(content).not.toContain("workspace:");
}
} finally {
rmSync(packedRoot, { recursive: true, force: true });
}
});
});

View File

@@ -77,10 +77,13 @@ describe("plugin-scaffold", () => {
const packageContents = readFileSync(join(outputDir, "package.json"), "utf-8");
const indexContents = readFileSync(join(outputDir, "src/index.ts"), "utf-8");
const readmeContents = readFileSync(join(outputDir, "README.md"), "utf-8");
expect(packageContents).not.toContain("@fusion/");
expect(packageContents).not.toContain("workspace:");
expect(indexContents).not.toContain("@fusion/");
expect(indexContents).not.toContain("workspace:");
expect(readmeContents).toContain("fn plugin dev .");
expect(readmeContents).toContain("fn plugin dev . --once");
const tsconfig = JSON.parse(readFileSync(join(outputDir, "tsconfig.json"), "utf-8")) as {
extends?: string;

View File

@@ -139,6 +139,7 @@ async function loadCommandHandlers() {
const { runChatInteractive } = await import("./commands/chat.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings, runPluginRescan } = await import("./commands/plugin.js");
const { runPluginCreate, runPluginNew } = await import("./commands/plugin-scaffold.js");
const { runPluginDev } = await import("./commands/plugin-dev.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
const { runExperimentFinalize } = await import("./commands/experiment-finalize.js");
@@ -236,6 +237,7 @@ async function loadCommandHandlers() {
runPluginRescan,
runPluginCreate,
runPluginNew,
runPluginDev,
runSkillsSearch,
runSkillsInstall,
runResearchCreate,
@@ -394,6 +396,7 @@ PR:
Install or uninstall plugin setup binaries/runtimes
fn plugin create <name> Scaffold a new plugin project
fn plugin new <name> Scaffold a standalone publishable plugin project
fn plugin dev <path> Build, install, and hot-reload a plugin locally
fn skills search <query> Search skills.sh for agent skills
fn skills search <query> --limit 5 Limit results
fn skills install <owner/repo> Install skills from a source
@@ -665,6 +668,7 @@ async function main() {
runPluginRescan,
runPluginCreate,
runPluginNew,
runPluginDev,
runSkillsSearch,
runSkillsInstall,
runResearchCreate,
@@ -1785,9 +1789,19 @@ async function main() {
});
break;
}
case "dev": {
const pluginPath = args[2];
if (!pluginPath) { console.error("Usage: fn plugin dev <path> [--once] [--ai-scan]"); process.exit(1); }
await runPluginDev(pluginPath, {
once: args.includes("--once"),
aiScan: args.includes("--ai-scan"),
projectName,
});
break;
}
default:
console.error(`Unknown subcommand: plugin ${sub || ""}`);
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new");
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new | dev");
process.exit(1);
}
break;

View File

@@ -0,0 +1,212 @@
import { existsSync, watch } from "node:fs";
import { join, resolve } from "node:path";
import { superviseSpawn } from "@fusion/core";
import {
createPluginLoader,
createPluginStore,
loadManifestFromPath,
resolvePluginEntryFile,
} from "./plugin.js";
interface DevWatchHandle {
close: () => void;
}
export interface RunPluginDevOptions {
projectName?: string;
once?: boolean;
aiScan?: boolean;
buildFn?: (dir: string) => Promise<void>;
watchFn?: (dir: string, onChange: () => void) => DevWatchHandle;
}
async function runSupervisedCommand(command: string, cwd: string, timeoutMs = 120_000): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = superviseSpawn(command, [], {
cwd,
shell: true,
stdio: "inherit",
env: process.env,
maxLifetimeMs: timeoutMs + 10_000,
});
const timer = setTimeout(() => {
child.kill("SIGTERM");
rejectPromise(new Error(`Command timed out: ${command}`));
}, timeoutMs);
timer.unref();
child.child.once("error", (error) => {
clearTimeout(timer);
rejectPromise(error);
});
child.waitExit().then((result) => {
clearTimeout(timer);
if (result.code === 0) {
resolvePromise();
return;
}
rejectPromise(new Error(`Command failed (${result.code ?? "signal"}): ${command}`));
}).catch((error) => {
clearTimeout(timer);
rejectPromise(error);
});
});
}
async function defaultBuildFn(pluginDir: string): Promise<void> {
try {
await runSupervisedCommand("pnpm build", pluginDir);
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("pnpm build")) {
throw error;
}
}
await runSupervisedCommand("npm run build", pluginDir);
}
function defaultWatchFn(pluginDir: string, onChange: () => void): DevWatchHandle {
const watchPath = existsSync(join(pluginDir, "src")) ? join(pluginDir, "src") : pluginDir;
try {
const watcher = watch(watchPath, { recursive: true }, onChange);
return { close: () => watcher.close() };
} catch {
const watcher = watch(watchPath, onChange);
return { close: () => watcher.close() };
}
}
function shouldUseWatcher(options?: RunPluginDevOptions): boolean {
if (options?.once) return false;
if (options?.watchFn) return true;
if (process.env.NODE_ENV === "test") return false;
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
}
export async function runPluginDev(source: string, options?: RunPluginDevOptions): Promise<void> {
if (!existsSync(source)) {
console.error(`Plugin path does not exist: ${source}`);
process.exit(1);
}
const pluginDir = resolve(source);
const buildFn = options?.buildFn ?? defaultBuildFn;
const watchFn = options?.watchFn ?? defaultWatchFn;
const { store, loader } = await createPluginLoader(
await createPluginStore(options?.projectName),
options?.projectName,
);
let pluginId: string | undefined;
let watcher: DevWatchHandle | undefined;
let closed = false;
let debounceTimer: NodeJS.Timeout | undefined;
let rebuilding = false;
let queued = false;
const installOrReload = async (reloadOnly = false): Promise<void> => {
await buildFn(pluginDir);
const entryPath = await resolvePluginEntryFile(pluginDir);
const { manifest } = await loadManifestFromPath(pluginDir);
if (!pluginId || !reloadOnly) {
const plugin = await store.registerPlugin({
manifest,
path: entryPath,
aiScanOnLoad: options?.aiScan ?? false,
});
pluginId = plugin.id;
if (plugin.enabled) {
await loader.loadPlugin(plugin.id);
}
return;
}
await loader.reloadPlugin(pluginId);
};
const onChange = (): void => {
if (closed || !pluginId) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
void (async () => {
if (rebuilding) {
queued = true;
return;
}
rebuilding = true;
try {
await installOrReload(true);
console.log(` ✓ Reloaded plugin ${pluginId}`);
} catch (error) {
console.error(` ⚠ Reload failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
rebuilding = false;
if (queued) {
queued = false;
onChange();
}
}
})();
}, 120);
debounceTimer.unref();
};
const close = async (): Promise<void> => {
if (closed) return;
closed = true;
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = undefined;
}
watcher?.close();
if (pluginId) {
try {
await loader.stopPlugin(pluginId);
} catch {
// Ignore teardown errors.
}
}
};
const sigintHandler = () => {
void close().finally(() => process.exit(0));
};
process.once("SIGINT", sigintHandler);
try {
await installOrReload(false);
if (!pluginId) {
throw new Error("Failed to install plugin");
}
console.log(` ✓ Built and loaded plugin ${pluginId}`);
if (!shouldUseWatcher(options)) {
return;
}
watcher = watchFn(pluginDir, onChange);
await new Promise<void>((resolvePromise) => {
const finish = () => {
process.off("SIGINT", finish);
resolvePromise();
};
process.on("SIGINT", finish);
});
} catch (error) {
console.error(` Failed to run plugin dev loop: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
} finally {
process.off("SIGINT", sigintHandler);
await close();
}
}

View File

@@ -355,17 +355,19 @@ A standalone Fusion plugin scaffold generated by \`fn plugin new\`.
\`\`\`bash
pnpm install
pnpm test
pnpm build
fn plugin dev .
pnpm test
\`\`\`
Use \`fn plugin dev . --once\` for a single build+install pass (CI-safe, no watcher).
## Publish
\`\`\`bash
npm publish
\`\`\`
> Note: local runtime dev-loop commands are delivered by sibling task FN-5844.
`;
}

View File

@@ -92,7 +92,7 @@ async function getProjectPath(projectName?: string): Promise<string> {
/**
* Create a PluginStore for the given project.
*/
async function createPluginStore(
export async function createPluginStore(
projectName?: string,
options?: { centralGlobalDir?: string },
): Promise<PluginStore> {
@@ -114,7 +114,7 @@ async function createPluginStore(
/**
* Create a PluginLoader for the given project.
*/
async function createPluginLoader(
export async function createPluginLoader(
pluginStore: PluginStore,
projectName?: string,
): Promise<{ store: PluginStore; loader: PluginLoader }> {
@@ -233,7 +233,7 @@ export async function resolvePluginEntryFile(pluginDir: string): Promise<string>
/**
* Load plugin manifest from a local path.
*/
async function loadManifestFromPath(
export async function loadManifestFromPath(
pluginPath: string,
): Promise<{ manifest: import("@fusion/core").PluginManifest; path: string }> {
const absoluteInputPath = resolve(pluginPath);