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:
5
.changeset/fn-5844-external-plugin-authoring.md
Normal file
5
.changeset/fn-5844-external-plugin-authoring.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add the external plugin authoring loop for published Fusion installs: `@runfusion/fusion/plugin-sdk` is available as the public SDK subpath, `fn plugin new <name>` scaffolds standalone publishable plugin packages, and `fn plugin dev <path>` builds, installs, watches, and hot-reloads local plugins during development.
|
||||||
@@ -5,6 +5,7 @@ A comprehensive guide to creating Fusion plugins that extend the task board with
|
|||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
1. [Getting Started](#1-getting-started)
|
1. [Getting Started](#1-getting-started)
|
||||||
|
- [External authoring guide](./plugins/external-authoring.md)
|
||||||
2. [Plugin Manifest Reference](#2-plugin-manifest-reference)
|
2. [Plugin Manifest Reference](#2-plugin-manifest-reference)
|
||||||
3. [Plugin Settings Schema](#3-plugin-settings-schema)
|
3. [Plugin Settings Schema](#3-plugin-settings-schema)
|
||||||
4. [Available Hooks and Signatures](#4-available-hooks-and-signatures)
|
4. [Available Hooks and Signatures](#4-available-hooks-and-signatures)
|
||||||
@@ -44,15 +45,18 @@ Fusion plugins extend the task board with custom functionality:
|
|||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
Create a new plugin using the scaffold command:
|
External authors should use the standalone scaffold and dev loop (see the [External Plugin Authoring guide](./plugins/external-authoring.md)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fn plugin create my-first-plugin
|
fn plugin new my-first-plugin
|
||||||
cd my-first-plugin
|
cd my-first-plugin
|
||||||
pnpm install
|
pnpm install
|
||||||
|
fn plugin dev .
|
||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The legacy `fn plugin create` scaffold remains available for workspace-bound examples.
|
||||||
|
|
||||||
### Optional AI Security Scan (Opt-in)
|
### Optional AI Security Scan (Opt-in)
|
||||||
|
|
||||||
Plugin installs now support an opt-in `aiScanOnLoad` flag. When enabled, Fusion runs an AI security review before loading plugin code.
|
Plugin installs now support an opt-in `aiScanOnLoad` flag. When enabled, Fusion runs an AI security review before loading plugin code.
|
||||||
@@ -1176,6 +1180,8 @@ This keeps regressions durable while preserving clear ownership boundaries acros
|
|||||||
|
|
||||||
## 13. Publishing Plugins
|
## 13. Publishing Plugins
|
||||||
|
|
||||||
|
For end-to-end standalone packaging, `pnpm pack`, and installing on another machine, follow the [External Plugin Authoring guide](./plugins/external-authoring.md).
|
||||||
|
|
||||||
### Package Requirements
|
### Package Requirements
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -987,9 +987,10 @@ fn plugin enable <id>
|
|||||||
fn plugin disable <id>
|
fn plugin disable <id>
|
||||||
fn plugin create <name>
|
fn plugin create <name>
|
||||||
fn plugin new <name> [--output <dir>] [--scope <scope>]
|
fn plugin new <name> [--output <dir>] [--scope <scope>]
|
||||||
|
fn plugin dev <path> [--once] [--ai-scan]
|
||||||
```
|
```
|
||||||
|
|
||||||
Subcommands: `list|ls`, `install`, `rescan`, `trust`, `untrust`, `verify`, `uninstall`, `enable`, `disable`, `create`, `new`.
|
Subcommands: `list|ls`, `install`, `rescan`, `trust`, `untrust`, `verify`, `uninstall`, `enable`, `disable`, `create`, `new`, `dev`.
|
||||||
|
|
||||||
Scope semantics:
|
Scope semantics:
|
||||||
- `fn plugin install` / `fn plugin uninstall` are **global** operations
|
- `fn plugin install` / `fn plugin uninstall` are **global** operations
|
||||||
|
|||||||
114
docs/plugins/external-authoring.md
Normal file
114
docs/plugins/external-authoring.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# External Plugin Authoring
|
||||||
|
|
||||||
|
This guide is for plugin authors using an installed `@runfusion/fusion` CLI. You do not need access to the Fusion monorepo.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- `pnpm` (or use the equivalent `npm` commands where noted)
|
||||||
|
- An installed Fusion CLI available as `fn`
|
||||||
|
|
||||||
|
## 1. Scaffold a standalone plugin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn plugin new my-plugin
|
||||||
|
cd my-plugin
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
The scaffold creates a standalone package named `fusion-plugin-my-plugin`. It depends on the published `@runfusion/fusion` package and imports SDK helpers from `@runfusion/fusion/plugin-sdk`; it must not contain private `@fusion/*` imports or `workspace:*` dependencies.
|
||||||
|
|
||||||
|
## 2. Develop locally with hot reload
|
||||||
|
|
||||||
|
Start Fusion locally, then run the plugin dev loop from the plugin directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn plugin dev .
|
||||||
|
```
|
||||||
|
|
||||||
|
`fn plugin dev .`:
|
||||||
|
|
||||||
|
1. Runs the plugin build script (`pnpm build`, with `npm run build` fallback).
|
||||||
|
2. Resolves the compiled JavaScript entry from `package.json` exports/main or `dist/index.js`.
|
||||||
|
3. Reads and validates the root `manifest.json`.
|
||||||
|
4. Installs and enables the plugin into the local Fusion plugin store.
|
||||||
|
5. Watches `src/` and, on save, rebuilds and hot-reloads with the Fusion plugin loader.
|
||||||
|
|
||||||
|
For a single CI-safe build/install/load pass without a watcher, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn plugin dev . --once
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also run the build yourself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Troubleshooting: plugin entrypoints must be compiled JavaScript. `fn plugin install` and `fn plugin dev` reject `.ts` source entrypoints, so run the build before installing if you are not using the dev loop.
|
||||||
|
|
||||||
|
## 3. Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer npm for a scaffold that supports it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Package
|
||||||
|
|
||||||
|
Build first, then create an npm tarball:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
pnpm pack
|
||||||
|
```
|
||||||
|
|
||||||
|
For `my-plugin` version `0.1.0`, the tarball is named like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fusion-plugin-my-plugin-0.1.0.tgz
|
||||||
|
```
|
||||||
|
|
||||||
|
The scaffolded package publishes only the files declared in `package.json` (`dist`, `manifest.json`, and package metadata) and exposes the plugin through:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Before sharing the tarball, confirm the artifact does not include private monorepo-only strings:
|
||||||
|
|
||||||
|
- no `@fusion/*` imports or dependencies
|
||||||
|
- no `workspace:*` dependency ranges
|
||||||
|
- SDK imports come from `@runfusion/fusion/plugin-sdk`
|
||||||
|
|
||||||
|
## 5. Install elsewhere
|
||||||
|
|
||||||
|
On another machine with Fusion installed, extract or install the tarball, then point Fusion at the extracted plugin directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf fusion-plugin-my-plugin-0.1.0.tgz
|
||||||
|
fn plugin install ./package
|
||||||
|
fn plugin enable fusion-plugin-my-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
If the plugin directory is already available, install it directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn plugin install /path/to/fusion-plugin-my-plugin
|
||||||
|
fn plugin enable fusion-plugin-my-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `fn plugin list` to confirm the plugin is installed and enabled for the current project.
|
||||||
@@ -105,6 +105,7 @@ const commandMocks = vi.hoisted(() => ({
|
|||||||
runPluginRescan: vi.fn(),
|
runPluginRescan: vi.fn(),
|
||||||
runPluginCreate: vi.fn(),
|
runPluginCreate: vi.fn(),
|
||||||
runPluginNew: vi.fn(),
|
runPluginNew: vi.fn(),
|
||||||
|
runPluginDev: vi.fn(),
|
||||||
|
|
||||||
runResearchCreate: vi.fn(),
|
runResearchCreate: vi.fn(),
|
||||||
runResearchList: vi.fn(),
|
runResearchList: vi.fn(),
|
||||||
@@ -266,6 +267,10 @@ vi.mock("../commands/plugin-scaffold.js", () => ({
|
|||||||
runPluginNew: commandMocks.runPluginNew,
|
runPluginNew: commandMocks.runPluginNew,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../commands/plugin-dev.js", () => ({
|
||||||
|
runPluginDev: commandMocks.runPluginDev,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/research.js", () => ({
|
vi.mock("../commands/research.js", () => ({
|
||||||
runResearchCreate: commandMocks.runResearchCreate,
|
runResearchCreate: commandMocks.runResearchCreate,
|
||||||
runResearchList: commandMocks.runResearchList,
|
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 () => {
|
it("shows plugin help guidance with install/add alias on unknown plugin subcommand", async () => {
|
||||||
await expect(runBin(["plugin", "oops"])).rejects.toThrow("process.exit:1");
|
await expect(runBin(["plugin", "oops"])).rejects.toThrow("process.exit:1");
|
||||||
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops");
|
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops");
|
||||||
expect(logSpy).toHaveBeenCalledWith(
|
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 packageContents = readFileSync(join(outputDir, "package.json"), "utf-8");
|
||||||
const indexContents = readFileSync(join(outputDir, "src/index.ts"), "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("@fusion/");
|
||||||
expect(packageContents).not.toContain("workspace:");
|
expect(packageContents).not.toContain("workspace:");
|
||||||
expect(indexContents).not.toContain("@fusion/");
|
expect(indexContents).not.toContain("@fusion/");
|
||||||
expect(indexContents).not.toContain("workspace:");
|
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 {
|
const tsconfig = JSON.parse(readFileSync(join(outputDir, "tsconfig.json"), "utf-8")) as {
|
||||||
extends?: string;
|
extends?: string;
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ async function loadCommandHandlers() {
|
|||||||
const { runChatInteractive } = await import("./commands/chat.js");
|
const { runChatInteractive } = await import("./commands/chat.js");
|
||||||
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings, runPluginRescan } = await import("./commands/plugin.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 { 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 { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
|
||||||
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
|
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
|
||||||
const { runExperimentFinalize } = await import("./commands/experiment-finalize.js");
|
const { runExperimentFinalize } = await import("./commands/experiment-finalize.js");
|
||||||
@@ -236,6 +237,7 @@ async function loadCommandHandlers() {
|
|||||||
runPluginRescan,
|
runPluginRescan,
|
||||||
runPluginCreate,
|
runPluginCreate,
|
||||||
runPluginNew,
|
runPluginNew,
|
||||||
|
runPluginDev,
|
||||||
runSkillsSearch,
|
runSkillsSearch,
|
||||||
runSkillsInstall,
|
runSkillsInstall,
|
||||||
runResearchCreate,
|
runResearchCreate,
|
||||||
@@ -394,6 +396,7 @@ PR:
|
|||||||
Install or uninstall plugin setup binaries/runtimes
|
Install or uninstall plugin setup binaries/runtimes
|
||||||
fn plugin create <name> Scaffold a new plugin project
|
fn plugin create <name> Scaffold a new plugin project
|
||||||
fn plugin new <name> Scaffold a standalone publishable 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> Search skills.sh for agent skills
|
||||||
fn skills search <query> --limit 5 Limit results
|
fn skills search <query> --limit 5 Limit results
|
||||||
fn skills install <owner/repo> Install skills from a source
|
fn skills install <owner/repo> Install skills from a source
|
||||||
@@ -665,6 +668,7 @@ async function main() {
|
|||||||
runPluginRescan,
|
runPluginRescan,
|
||||||
runPluginCreate,
|
runPluginCreate,
|
||||||
runPluginNew,
|
runPluginNew,
|
||||||
|
runPluginDev,
|
||||||
runSkillsSearch,
|
runSkillsSearch,
|
||||||
runSkillsInstall,
|
runSkillsInstall,
|
||||||
runResearchCreate,
|
runResearchCreate,
|
||||||
@@ -1785,9 +1789,19 @@ async function main() {
|
|||||||
});
|
});
|
||||||
break;
|
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:
|
default:
|
||||||
console.error(`Unknown subcommand: plugin ${sub || ""}`);
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
break;
|
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
|
\`\`\`bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm test
|
|
||||||
pnpm build
|
pnpm build
|
||||||
|
fn plugin dev .
|
||||||
|
pnpm test
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
Use \`fn plugin dev . --once\` for a single build+install pass (CI-safe, no watcher).
|
||||||
|
|
||||||
## Publish
|
## Publish
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
npm publish
|
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.
|
* Create a PluginStore for the given project.
|
||||||
*/
|
*/
|
||||||
async function createPluginStore(
|
export async function createPluginStore(
|
||||||
projectName?: string,
|
projectName?: string,
|
||||||
options?: { centralGlobalDir?: string },
|
options?: { centralGlobalDir?: string },
|
||||||
): Promise<PluginStore> {
|
): Promise<PluginStore> {
|
||||||
@@ -114,7 +114,7 @@ async function createPluginStore(
|
|||||||
/**
|
/**
|
||||||
* Create a PluginLoader for the given project.
|
* Create a PluginLoader for the given project.
|
||||||
*/
|
*/
|
||||||
async function createPluginLoader(
|
export async function createPluginLoader(
|
||||||
pluginStore: PluginStore,
|
pluginStore: PluginStore,
|
||||||
projectName?: string,
|
projectName?: string,
|
||||||
): Promise<{ store: PluginStore; loader: PluginLoader }> {
|
): Promise<{ store: PluginStore; loader: PluginLoader }> {
|
||||||
@@ -233,7 +233,7 @@ export async function resolvePluginEntryFile(pluginDir: string): Promise<string>
|
|||||||
/**
|
/**
|
||||||
* Load plugin manifest from a local path.
|
* Load plugin manifest from a local path.
|
||||||
*/
|
*/
|
||||||
async function loadManifestFromPath(
|
export async function loadManifestFromPath(
|
||||||
pluginPath: string,
|
pluginPath: string,
|
||||||
): Promise<{ manifest: import("@fusion/core").PluginManifest; path: string }> {
|
): Promise<{ manifest: import("@fusion/core").PluginManifest; path: string }> {
|
||||||
const absoluteInputPath = resolve(pluginPath);
|
const absoluteInputPath = resolve(pluginPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user