FN-5843: scaffold standalone external-author plugins with reference template

Add a standalone external-author plugin scaffold flow and reference plugin output for fn plugin new.

- add standalone scaffold mode to plugin generator with external-author defaults
- generate reference plugin files and metadata tailored for out-of-tree/plugin-author usage
- update CLI wiring and command behavior for the new scaffold path
- expand CLI and scaffold tests to cover standalone generation behavior
- document the updated plugin scaffolding flow in CLI reference docs

Files changed:
 docs/cli-reference.md                              |   3 +-
 packages/cli/src/__tests__/bin.test.ts             |  12 +-
 packages/cli/src/__tests__/plugin-scaffold.test.ts | 114 ++++++---
 packages/cli/src/bin.ts                            |  18 +-
 packages/cli/src/commands/plugin-scaffold.ts       | 269 +++++++++++++++++++--
 5 files changed, 356 insertions(+), 60 deletions(-)

Fusion-Task-Id: FN-5843

Fusion-Task-Lineage: 4383e9ed-0b12-451e-a946-2d002f7e8faf
This commit is contained in:
gsxdsm
2026-06-01 16:20:52 -07:00
parent af6e5b5eaf
commit 9cbb54906f
5 changed files with 359 additions and 63 deletions

View File

@@ -102,6 +102,7 @@ const commandMocks = vi.hoisted(() => ({
runPluginSettings: vi.fn(),
runPluginRescan: vi.fn(),
runPluginCreate: vi.fn(),
runPluginNew: vi.fn(),
runResearchCreate: vi.fn(),
runResearchList: vi.fn(),
@@ -238,6 +239,7 @@ vi.mock("../commands/plugin.js", () => ({
vi.mock("../commands/plugin-scaffold.js", () => ({
runPluginCreate: commandMocks.runPluginCreate,
runPluginNew: commandMocks.runPluginNew,
}));
vi.mock("../commands/research.js", () => ({
@@ -507,11 +509,19 @@ describe("bin command routing and fallbacks", () => {
);
});
it("routes plugin new with scope and output flags", async () => {
await runBin(["plugin", "new", "hello-plugin", "--scope", "acme", "--output", "./hello-plugin"]);
expect(commandMocks.runPluginNew).toHaveBeenCalledWith("hello-plugin", {
scope: "acme",
output: "./hello-plugin",
});
});
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",
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new",
);
});

View File

@@ -1,14 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterAll } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { runPluginCreate } from "../commands/plugin-scaffold.js";
import { validatePluginManifest } from "@fusion/plugin-sdk";
import { resolvePluginEntryFile } from "../commands/plugin.js";
import { runPluginCreate, runPluginNew } from "../commands/plugin-scaffold.js";
describe("plugin-scaffold", () => {
const tmpBase = join(tmpdir(), `fn-scaffold-${Date.now()}-${Math.random().toString(36).slice(2)}`);
beforeEach(() => {
// Ensure temp directory exists
mkdirSync(tmpBase, { recursive: true });
});
@@ -25,7 +26,7 @@ describe("plugin-scaffold", () => {
const exitMock = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("exit");
});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation();
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
runPluginCreate("Test-Plugin", { output: join(tmpBase, "test1") }),
@@ -37,56 +38,93 @@ describe("plugin-scaffold", () => {
exitMock.mockRestore();
consoleErrorSpy.mockRestore();
});
});
it("should reject invalid plugin name (spaces)", async () => {
describe("runPluginNew", () => {
it("scaffolds standalone plugin output with required files and fields", async () => {
const outputDir = join(tmpBase, "hello-plugin");
await runPluginNew("hello-plugin", { output: outputDir });
const expectedFiles = [
"package.json",
"tsconfig.json",
"vitest.config.ts",
"manifest.json",
"src/index.ts",
"src/__tests__/index.test.ts",
"README.md",
];
for (const file of expectedFiles) {
expect(existsSync(join(outputDir, file))).toBe(true);
}
const packageJson = JSON.parse(readFileSync(join(outputDir, "package.json"), "utf-8")) as {
name: string;
private?: boolean;
keywords: string[];
exports: { ".": { types: string; import: string } };
devDependencies: Record<string, string>;
};
expect(packageJson.name).toBe("fusion-plugin-hello-plugin");
expect(packageJson.keywords).toContain("fusion-plugin");
expect(packageJson.private).toBeUndefined();
expect(packageJson.exports["."].types).toBe("./dist/index.d.ts");
expect(packageJson.exports["."].import).toBe("./dist/index.js");
expect(Object.keys(packageJson.devDependencies)).toEqual(["@runfusion/fusion"]);
expect(packageJson.devDependencies["@runfusion/fusion"]).toMatch(/^\^\d+\.\d+\.\d+$/);
const packageContents = readFileSync(join(outputDir, "package.json"), "utf-8");
const indexContents = readFileSync(join(outputDir, "src/index.ts"), "utf-8");
expect(packageContents).not.toContain("@fusion/");
expect(packageContents).not.toContain("workspace:");
expect(indexContents).not.toContain("@fusion/");
expect(indexContents).not.toContain("workspace:");
const tsconfig = JSON.parse(readFileSync(join(outputDir, "tsconfig.json"), "utf-8")) as {
extends?: string;
};
expect(tsconfig.extends).toBeUndefined();
});
it("supports scoped package names", async () => {
const outputDir = join(tmpBase, "scoped-plugin");
await runPluginNew("scoped-plugin", { output: outputDir, scope: "acme" });
const packageJson = JSON.parse(readFileSync(join(outputDir, "package.json"), "utf-8")) as {
name: string;
};
expect(packageJson.name).toBe("@acme/fusion-plugin-scoped-plugin");
});
it("rejects invalid plugin names", async () => {
const exitMock = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("exit");
});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation();
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
runPluginCreate("test plugin", { output: join(tmpBase, "test2") }),
).rejects.toThrow("exit");
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid plugin name"),
await expect(runPluginNew("Bad Plugin", { output: join(tmpBase, "bad") })).rejects.toThrow(
"exit",
);
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid plugin name"));
exitMock.mockRestore();
consoleErrorSpy.mockRestore();
});
it("should reject invalid plugin name (special characters)", async () => {
const exitMock = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("exit");
});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation();
it("produces manifest accepted by validator and loader entry seam", async () => {
const outputDir = join(tmpBase, "loader-plugin");
await runPluginNew("loader-plugin", { output: outputDir });
await expect(
runPluginCreate("test@plugin", { output: join(tmpBase, "test3") }),
).rejects.toThrow("exit");
const manifest = JSON.parse(readFileSync(join(outputDir, "manifest.json"), "utf-8"));
expect(validatePluginManifest(manifest)).toEqual({ valid: true, errors: [] });
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid plugin name"),
);
exitMock.mockRestore();
consoleErrorSpy.mockRestore();
});
const distDir = join(outputDir, "dist");
mkdirSync(distDir, { recursive: true });
const entryPath = join(distDir, "index.js");
writeFileSync(entryPath, "export default {};\n");
it("should reject empty plugin name", async () => {
const exitMock = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("exit");
});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation();
await expect(
runPluginCreate("", { output: join(tmpBase, "test4") }),
).rejects.toThrow("exit");
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid plugin name"),
);
exitMock.mockRestore();
consoleErrorSpy.mockRestore();
await expect(resolvePluginEntryFile(outputDir)).resolves.toBe(entryPath);
});
});
});

View File

@@ -138,7 +138,7 @@ async function loadCommandHandlers() {
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.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 { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runPluginCreate, runPluginNew } = await import("./commands/plugin-scaffold.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");
@@ -235,6 +235,7 @@ async function loadCommandHandlers() {
runPluginSettings,
runPluginRescan,
runPluginCreate,
runPluginNew,
runSkillsSearch,
runSkillsInstall,
runResearchCreate,
@@ -390,6 +391,7 @@ PR:
fn plugin setup <id> [--action install|uninstall]
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 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
@@ -660,6 +662,7 @@ async function main() {
runPluginSettings,
runPluginRescan,
runPluginCreate,
runPluginNew,
runSkillsSearch,
runSkillsInstall,
runResearchCreate,
@@ -1768,12 +1771,21 @@ async function main() {
case "create": {
const pluginName = args[2];
if (!pluginName) { console.error("Usage: fn plugin create <name>"); process.exit(1); }
await runPluginCreate(pluginName);
await runPluginCreate(pluginName, { output: getFlagValue(args.slice(3), "--output") });
break;
}
case "new": {
const pluginName = args[2];
if (!pluginName) { console.error("Usage: fn plugin new <name> [--output <dir>] [--scope <scope>]"); process.exit(1); }
await runPluginNew(pluginName, {
output: getFlagValue(args.slice(3), "--output"),
scope: getFlagValue(args.slice(3), "--scope"),
});
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");
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create | new");
process.exit(1);
}
break;

View File

@@ -1,15 +1,17 @@
/**
* Plugin Scaffold Command
*
* Generates a new plugin project with boilerplate code.
* Generates plugin projects with boilerplate code.
* Usage: fn plugin create <name>
*/
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// Valid plugin name pattern: kebab-case
const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const DEFAULT_RUNFUSION_VERSION = "0.39.0";
/**
* Convert a kebab-case string to Title Case
@@ -21,8 +23,81 @@ function toTitleCase(str: string): string {
.join(" ");
}
function failInvalidName(name: string, command: "create" | "new"): never {
console.error(
`Invalid plugin name '${name}'. Must be kebab-case (lowercase letters, numbers, hyphens).`,
);
console.error(`Example: fn plugin ${command} my-awesome-plugin`);
process.exit(1);
}
function resolveTargetPath(name: string, output?: string): { targetDir: string; targetPath: string } {
const targetDir = output ?? name;
return {
targetDir,
targetPath: resolve(process.cwd(), targetDir),
};
}
function ensureTargetPathAvailable(targetDir: string, targetPath: string): void {
if (existsSync(targetPath)) {
console.error(`Error: Directory '${targetDir}' already exists.`);
console.error("Please choose a different name or remove the existing directory.");
process.exit(1);
}
}
function readOwnCliVersion(): string | undefined {
let currentDir: string;
try {
currentDir = dirname(fileURLToPath(import.meta.url));
} catch {
return undefined;
}
for (let i = 0; i < 8; i += 1) {
const pkgPath = resolve(currentDir, "package.json");
if (existsSync(pkgPath)) {
try {
const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
name?: string;
version?: string;
};
if (parsed.name === "@runfusion/fusion" && typeof parsed.version === "string") {
return parsed.version;
}
} catch {
// Ignore malformed package.json and continue walking.
}
}
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
}
return undefined;
}
function resolveFusionCaretVersion(): string {
const version = readOwnCliVersion() ?? DEFAULT_RUNFUSION_VERSION;
return `^${version}`;
}
function normalizeScope(scope?: string): string | undefined {
if (!scope) return undefined;
return scope.startsWith("@") ? scope.slice(1) : scope;
}
function computeStandalonePackageName(name: string, scope?: string): string {
const scoped = normalizeScope(scope);
if (scoped) {
return `@${scoped}/fusion-plugin-${name}`;
}
return `fusion-plugin-${name}`;
}
/**
* Generate package.json template
* Generate package.json template for workspace-bound example plugin.
*/
function generatePackageJson(name: string): string {
return JSON.stringify(
@@ -53,7 +128,7 @@ function generatePackageJson(name: string): string {
}
/**
* Generate tsconfig.json template
* Generate tsconfig.json template for workspace-bound example plugin.
*/
function generateTsconfig(): string {
return JSON.stringify(
@@ -71,6 +146,69 @@ function generateTsconfig(): string {
) + "\n";
}
function generateStandalonePackageJson(name: string, scope?: string): string {
return JSON.stringify(
{
name: computeStandalonePackageName(name, scope),
version: "0.1.0",
type: "module",
description: "A standalone Fusion plugin",
keywords: ["fusion-plugin"],
exports: {
".": {
types: "./dist/index.d.ts",
import: "./dist/index.js",
},
},
files: ["dist", "manifest.json"],
scripts: {
build: "tsc",
test: "vitest run",
},
devDependencies: {
"@runfusion/fusion": resolveFusionCaretVersion(),
},
},
null,
2,
) + "\n";
}
function generateStandaloneTsconfig(): string {
return JSON.stringify(
{
compilerOptions: {
module: "NodeNext",
moduleResolution: "NodeNext",
target: "ES2022",
declaration: true,
outDir: "dist",
rootDir: "src",
strict: true,
types: ["node"],
},
include: ["src/**/*"],
exclude: ["src/**/*.test.ts", "dist"],
},
null,
2,
) + "\n";
}
function generateManifest(name: string): string {
const titleCase = toTitleCase(name);
return JSON.stringify(
{
id: name,
name: titleCase,
version: "0.1.0",
description: `A standalone Fusion plugin named ${titleCase}.`,
},
null,
2,
) + "\n";
}
/**
* Generate vitest.config.ts template
*/
@@ -113,6 +251,26 @@ export default definePlugin({
`;
}
function generateStandaloneIndexTs(name: string): string {
const titleCase = toTitleCase(name);
return `import { definePlugin } from "@runfusion/fusion/plugin-sdk";
export default definePlugin({
manifest: {
id: "${name}",
name: "${titleCase}",
version: "0.1.0",
description: "A standalone Fusion plugin",
},
hooks: {
onLoad: async (ctx) => {
ctx.logger.info("${titleCase} plugin loaded");
},
},
});
`;
}
/**
* Generate src/__tests__/index.test.ts template
*/
@@ -135,6 +293,28 @@ describe("${titleCase} plugin", () => {
`;
}
function generateStandaloneTestTs(name: string): string {
const titleCase = toTitleCase(name);
return `import { describe, expect, it } from "vitest";
import { validatePluginManifest } from "@runfusion/fusion/plugin-sdk";
import plugin from "../index.js";
describe("${titleCase} plugin", () => {
it("exports the expected manifest fields", () => {
expect(plugin.manifest.id).toBe("${name}");
expect(plugin.manifest.name).toBe("${titleCase}");
expect(plugin.manifest.version).toBe("0.1.0");
});
it("has a manifest accepted by validatePluginManifest", () => {
const result = validatePluginManifest(plugin.manifest);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
});
`;
}
/**
* Generate README.md template
*/
@@ -165,6 +345,30 @@ MIT
`;
}
function generateStandaloneReadme(name: string): string {
const titleCase = toTitleCase(name);
return `# ${titleCase}
A standalone Fusion plugin scaffold generated by \`fn plugin new\`.
## Development
\`\`\`bash
pnpm install
pnpm test
pnpm build
\`\`\`
## Publish
\`\`\`bash
npm publish
\`\`\`
> Note: local runtime dev-loop commands are delivered by sibling task FN-5844.
`;
}
/**
* Run the plugin scaffold command
*/
@@ -174,23 +378,14 @@ export async function runPluginCreate(
): Promise<void> {
// Validate name
if (!name || !PLUGIN_NAME_REGEX.test(name)) {
console.error(
`Invalid plugin name '${name}'. Must be kebab-case (lowercase letters, numbers, hyphens).`,
);
console.error("Example: fn plugin create my-awesome-plugin");
process.exit(1);
failInvalidName(name, "create");
}
// Determine target directory
const targetDir = options?.output ?? name;
const targetPath = join(process.cwd(), targetDir);
const { targetDir, targetPath } = resolveTargetPath(name, options?.output);
// Check if directory already exists
if (existsSync(targetPath)) {
console.error(`Error: Directory '${targetDir}' already exists.`);
console.error("Please choose a different name or remove the existing directory.");
process.exit(1);
}
ensureTargetPathAvailable(targetDir, targetPath);
// Create directory structure
try {
@@ -225,3 +420,43 @@ export async function runPluginCreate(
console.log(" pnpm test");
console.log();
}
export async function runPluginNew(
name: string,
options?: { output?: string; scope?: string },
): Promise<void> {
if (!name || !PLUGIN_NAME_REGEX.test(name)) {
failInvalidName(name, "new");
}
const { targetDir, targetPath } = resolveTargetPath(name, options?.output);
ensureTargetPathAvailable(targetDir, targetPath);
try {
mkdirSync(targetPath, { recursive: true });
mkdirSync(join(targetPath, "src", "__tests__"), { recursive: true });
writeFileSync(join(targetPath, "package.json"), generateStandalonePackageJson(name, options?.scope));
writeFileSync(join(targetPath, "tsconfig.json"), generateStandaloneTsconfig());
writeFileSync(join(targetPath, "vitest.config.ts"), generateVitestConfig());
writeFileSync(join(targetPath, "manifest.json"), generateManifest(name));
writeFileSync(join(targetPath, "src", "index.ts"), generateStandaloneIndexTs(name));
writeFileSync(join(targetPath, "src", "__tests__", "index.test.ts"), generateStandaloneTestTs(name));
writeFileSync(join(targetPath, "README.md"), generateStandaloneReadme(name));
} catch (err) {
console.error(
`Error creating plugin files: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
console.log();
console.log(` Created standalone plugin at ./${targetDir}/`);
console.log();
console.log(" Next steps:");
console.log(` cd ${targetDir}`);
console.log(" pnpm install");
console.log(" pnpm test");
console.log(" pnpm build");
console.log();
}