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
213 lines
5.6 KiB
TypeScript
213 lines
5.6 KiB
TypeScript
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();
|
|
}
|
|
}
|