Files
fusion/packages/cli/src/commands/plugin-scaffold.ts
gsxdsm 59f2596c61 FN-6435: add plugin scaffold state
Ensure generated plugin scaffolds include the FusionPlugin state required by the SDK contract.

- Add state: "installed" to workspace and standalone plugin scaffold outputs.
- Cover scaffold output with tests and a compile-time FusionPlugin fixture.
- Document the state field in plugin authoring guidance and add a patch changeset.

Files changed:
 .changeset/FN-6435-plugin-scaffold-state.md        |  9 +++++++
 docs/PLUGIN_AUTHORING.md                           |  2 ++
 packages/cli/src/__tests__/plugin-scaffold.test.ts | 22 ++++++++++++++--
 packages/cli/src/commands/plugin-scaffold.ts       |  5 ++++
 .../type-guards/plugin-scaffold-fusion-plugin.ts   | 30 ++++++++++++++++++++++
 5 files changed, 66 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6435

Fusion-Task-Lineage: 8c7e2f5d-9dd4-4058-aef9-11d6ff0ba5e0
2026-06-14 15:29:35 -07:00

476 lines
12 KiB
TypeScript

/**
* Plugin Scaffold Command
*
* Generates plugin projects with boilerplate code.
* Usage: fn plugin create <name>
*/
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";
const SCAFFOLD_TYPES_NODE_VERSION = "^22.0.0";
const SCAFFOLD_VITEST_VERSION = "^4.1.0";
const SCAFFOLD_TYPESCRIPT_VERSION = "^5.7.0";
/**
* Convert a kebab-case string to Title Case
*/
function toTitleCase(str: string): string {
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.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 for workspace-bound example plugin.
*/
function generatePackageJson(name: string): string {
return JSON.stringify(
{
name: `@fusion-plugin-examples/${name}`,
version: "0.1.0",
type: "module",
description: "A Fusion plugin",
keywords: ["fusion-plugin"],
exports: {
".": {
types: "./src/index.ts",
import: "./dist/index.js",
},
},
private: true,
scripts: {
build: "tsc",
test: "vitest run",
},
dependencies: {
"@fusion/plugin-sdk": "workspace:*",
},
},
null,
2,
) + "\n";
}
/**
* Generate tsconfig.json template for workspace-bound example plugin.
*/
function generateTsconfig(): string {
return JSON.stringify(
{
extends: "../../tsconfig.base.json",
compilerOptions: {
outDir: "dist",
rootDir: "src",
types: ["node", "vitest/globals"],
},
include: ["src/**/*"],
},
null,
2,
) + "\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(),
"@types/node": SCAFFOLD_TYPES_NODE_VERSION,
typescript: SCAFFOLD_TYPESCRIPT_VERSION,
vitest: SCAFFOLD_VITEST_VERSION,
},
},
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
*/
function generateVitestConfig(): string {
return `import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
pool: "threads",
},
});
`;
}
/**
* Generate src/index.ts template
*/
function generateIndexTs(name: string): string {
const titleCase = toTitleCase(name);
return `import { definePlugin } from "@fusion/plugin-sdk";
export default definePlugin({
manifest: {
id: "${name}",
name: "${titleCase}",
version: "0.1.0",
description: "A new Fusion plugin",
},
state: "installed",
hooks: {
onLoad: async (ctx) => {
ctx.logger.info("${name} plugin loaded");
},
onUnload: async () => {
// Cleanup resources
},
},
});
`;
}
/**
* FNXC:PluginScaffold 2026-06-14-01:40:
* The published FusionPlugin type requires state: PluginState, so standalone `fn plugin new` output must emit `state: "installed"` and stay in sync with the workspace scaffold plus SDK type surface to build unedited.
*/
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",
},
state: "installed",
hooks: {
onLoad: async (ctx) => {
ctx.logger.info("${titleCase} plugin loaded");
},
},
});
`;
}
/**
* Generate src/__tests__/index.test.ts template
*/
function generateTestTs(name: string): string {
const titleCase = toTitleCase(name);
return `import { describe, it, expect } from "vitest";
import plugin from "../index.js";
describe("${titleCase} plugin", () => {
it("should export a valid plugin definition", () => {
expect(plugin.manifest.id).toBe("${name}");
expect(plugin.manifest.name).toBe("${titleCase}");
expect(plugin.manifest.version).toBe("0.1.0");
});
it("should have a valid state", () => {
expect(plugin.state).toBe("installed");
});
});
`;
}
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
*/
function generateReadme(name: string): string {
const titleCase = toTitleCase(name);
return `# ${titleCase}
A Fusion plugin.
## Installation
\`\`\`bash
fn plugin install /path/to/${name}
\`\`\`
## Development
\`\`\`bash
pnpm install
pnpm lint
pnpm test
pnpm build
\`\`\`
## License
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 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
\`\`\`
`;
}
/**
* Run the plugin scaffold command
*/
export async function runPluginCreate(
name: string,
options?: { output?: string },
): Promise<void> {
// Validate name
if (!name || !PLUGIN_NAME_REGEX.test(name)) {
failInvalidName(name, "create");
}
// Determine target directory
const { targetDir, targetPath } = resolveTargetPath(name, options?.output);
// Check if directory already exists
ensureTargetPathAvailable(targetDir, targetPath);
// Create directory structure
try {
mkdirSync(targetPath, { recursive: true });
mkdirSync(join(targetPath, "src", "__tests__"), { recursive: true });
// Generate files
writeFileSync(join(targetPath, "package.json"), generatePackageJson(name));
writeFileSync(join(targetPath, "tsconfig.json"), generateTsconfig());
writeFileSync(join(targetPath, "vitest.config.ts"), generateVitestConfig());
writeFileSync(join(targetPath, "src", "index.ts"), generateIndexTs(name));
writeFileSync(
join(targetPath, "src", "__tests__", "index.test.ts"),
generateTestTs(name),
);
writeFileSync(join(targetPath, "README.md"), generateReadme(name));
} catch (err) {
console.error(
`Error creating plugin files: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
// Print success message
console.log();
console.log(` Created plugin at ./${targetDir}/`);
console.log();
console.log(" Next steps:");
console.log(` cd ${targetDir}`);
console.log(" pnpm install");
console.log(" pnpm lint");
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();
}