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:
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
138
packages/cli/src/__tests__/plugin-dev.test.ts
Normal file
138
packages/cli/src/__tests__/plugin-dev.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
94
packages/cli/src/__tests__/plugin-pack-shape.test.ts
Normal file
94
packages/cli/src/__tests__/plugin-pack-shape.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
212
packages/cli/src/commands/plugin-dev.ts
Normal file
212
packages/cli/src/commands/plugin-dev.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user