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:
@@ -971,9 +971,10 @@ fn plugin uninstall <id> --force
|
||||
fn plugin enable <id>
|
||||
fn plugin disable <id>
|
||||
fn plugin create <name>
|
||||
fn plugin new <name> [--output <dir>] [--scope <scope>]
|
||||
```
|
||||
|
||||
Subcommands: `list|ls`, `install`, `rescan`, `trust`, `untrust`, `verify`, `uninstall`, `enable`, `disable`, `create`.
|
||||
Subcommands: `list|ls`, `install`, `rescan`, `trust`, `untrust`, `verify`, `uninstall`, `enable`, `disable`, `create`, `new`.
|
||||
|
||||
Scope semantics:
|
||||
- `fn plugin install` / `fn plugin uninstall` are **global** operations
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user