diff --git a/.changeset/FN-3763-cli-printing-press-wizard.md b/.changeset/FN-3763-cli-printing-press-wizard.md new file mode 100644 index 000000000..e39835980 --- /dev/null +++ b/.changeset/FN-3763-cli-printing-press-wizard.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Add a bundled `fusion-plugin-cli-printing-press` plugin with a plugin-owned Create Service wizard view and draft-save API scaffold. diff --git a/packages/dashboard/app/plugins/__tests__/pluginViewRegistry.test.tsx b/packages/dashboard/app/plugins/__tests__/pluginViewRegistry.test.tsx index a484a4a29..7be73271b 100644 --- a/packages/dashboard/app/plugins/__tests__/pluginViewRegistry.test.tsx +++ b/packages/dashboard/app/plugins/__tests__/pluginViewRegistry.test.tsx @@ -35,6 +35,12 @@ describe("pluginViewRegistry", () => { expect(getPluginViewComponent("plugin-b", "missing")).toBeNull(); }); + it("resolves cli printing press wizard entries", () => { + const View = lazy(async () => ({ default: () =>
Wizard
})); + registerPluginView("fusion-plugin-cli-printing-press", "wizard", View); + expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBe(View); + }); + it("keeps all registered plugin views discoverable by key iteration", () => { const ViewA = lazy(async () => ({ default: () =>
A
})); const ViewB = lazy(async () => ({ default: () =>
B
})); diff --git a/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx b/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx index df8db3f38..f26e18ef6 100644 --- a/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx +++ b/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx @@ -8,6 +8,7 @@ import { const MockDependencyGraphDashboardView = () => createElement("div", { "data-testid": "dep-graph-view" }); const MockRoadmapDashboardView = () => createElement("div", { "data-testid": "roadmap-view" }); +const MockCliPrintingPressWizardView = () => createElement("div", { "data-testid": "cli-printing-press-view" }); vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({ DependencyGraphDashboardView: (...args: unknown[]) => MockDependencyGraphDashboardView(...args), @@ -17,17 +18,22 @@ vi.mock("@fusion-plugin-examples/roadmap/dashboard-view", () => ({ RoadmapDashboardView: (...args: unknown[]) => MockRoadmapDashboardView(...args), })); +vi.mock("@fusion-plugin-examples/cli-printing-press/dashboard-view", () => ({ + CliPrintingPressWizardView: (...args: unknown[]) => MockCliPrintingPressWizardView(...args), +})); + describe("registerBundledPluginViews", () => { beforeEach(() => { __test_clearPluginViewRegistry(); __test_resetBundledPluginViewRegistration(); }); - it("registers dependency graph and roadmap bundled views", () => { + it("registers dependency graph, roadmap, and cli printing press bundled views", () => { registerBundledPluginViews(); expect(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBeTruthy(); expect(getPluginViewComponent("roadmap-planner", "roadmaps")).toBeTruthy(); + expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBeTruthy(); }); it("is idempotent when called more than once", () => { @@ -45,6 +51,7 @@ describe("registerBundledPluginViews", () => { expect(isPluginViewRegistered("fusion-plugin-dependency-graph", "graph")).toBe(true); expect(isPluginViewRegistered("roadmap-planner", "roadmaps")).toBe(true); + expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "wizard")).toBe(true); // Unknown plugin/view should not be registered expect(isPluginViewRegistered("unknown-plugin", "unknown")).toBe(false); }); diff --git a/packages/dashboard/app/plugins/registerBundledPluginViews.ts b/packages/dashboard/app/plugins/registerBundledPluginViews.ts index 111e01f1d..8587f050b 100644 --- a/packages/dashboard/app/plugins/registerBundledPluginViews.ts +++ b/packages/dashboard/app/plugins/registerBundledPluginViews.ts @@ -37,6 +37,18 @@ async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> { return { default: component as PluginViewComponent }; } +async function loadCliPrintingPressWizardView(): Promise<{ default: PluginViewComponent }> { + const moduleId = "@fusion-plugin-examples/cli-printing-press/dashboard-view"; + const exportName = "CliPrintingPressWizardView"; + const mod = await import("@fusion-plugin-examples/cli-printing-press/dashboard-view") as unknown as Record>; + const component = mod[exportName]; + if (!component) { + console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`); + return { default: createMissingPluginView(moduleId, exportName) }; + } + return { default: component as PluginViewComponent }; +} + export function registerBundledPluginViews(): void { if (registered) return; registered = true; @@ -52,6 +64,12 @@ export function registerBundledPluginViews(): void { "roadmaps", lazy(loadRoadmapView), ); + + registerPluginView( + "fusion-plugin-cli-printing-press", + "wizard", + lazy(loadCliPrintingPressWizardView), + ); } export function __test_resetBundledPluginViewRegistration(): void { diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 4eff1550f..70e5a3ae8 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -79,6 +79,7 @@ "@fusion-plugin-examples/openclaw-runtime": "workspace:*", "@fusion-plugin-examples/droid-runtime": "workspace:*", "@fusion-plugin-examples/cursor-runtime": "workspace:*", + "@fusion-plugin-examples/cli-printing-press": "workspace:*", "@fusion-plugin-examples/paperclip-runtime": "workspace:*", "@fusion/core": "workspace:*", "@fusion/engine": "workspace:*", diff --git a/plugins/fusion-plugin-cli-printing-press/README.md b/plugins/fusion-plugin-cli-printing-press/README.md index 1ac4b0dc6..935b66d5b 100644 --- a/plugins/fusion-plugin-cli-printing-press/README.md +++ b/plugins/fusion-plugin-cli-printing-press/README.md @@ -1,9 +1,33 @@ # fusion-plugin-cli-printing-press -Bundled first-party Fusion plugin integrating [cli-printing-press](https://github.com/mvanhorn/cli-printing-press) for generating and managing CLIs for external services. +Bundled first-party Fusion plugin that adds a plugin-owned dashboard wizard for drafting an external service CLI definition. -## Status +## v1 scope (FN-3763) -v1 design: see `docs/design/cli-printing-press-plugin.md`. +- Provides one dashboard view: **Create Service CLI** (`viewId: wizard`) +- Wizard collects service basics, HTTP transport details, endpoints, and non-OAuth credential placeholders +- Saves draft payloads to interim JSON files under: + - `/.fusion/plugins/cli-printing-press/drafts/.json` +- Success state is **draft saved** only -Implementation forthcoming in FN-3763 through FN-3770. This directory currently contains a no-op plugin stub that registers with the Fusion plugin system and passes manifest validation. +## Provisional architecture assumptions (pending FN-3762/FN-3766) + +The following choices are intentionally provisional and may be revised by architecture/storage follow-up work: + +- `PluginContext` usage pattern in route handlers +- Express route shape and plugin-relative path conventions +- Credential union shape for wizard payloads (non-OAuth only in v1) +- Draft storage location and JSON schema + +## Deferred follow-ups + +- OAuth credential flows: **FN-3762 / FN-3766** +- Draft management views (list/inspect/edit): **FN-3764** +- Run/test and regenerate actions: **FN-3765** +- Canonical storage migration (replace JSON stash): **FN-3766** +- Runtime exposure/integration: **FN-3767** +- Workflow-step exposure: **FN-3768** + +## Frontend API target + +The wizard posts drafts to `/api/plugins/fusion-plugin-cli-printing-press/drafts` (host-prefixed plugin route target), following the roadmap plugin routing convention. diff --git a/plugins/fusion-plugin-cli-printing-press/manifest.json b/plugins/fusion-plugin-cli-printing-press/manifest.json index 5ac1ab438..ca410d8c5 100644 --- a/plugins/fusion-plugin-cli-printing-press/manifest.json +++ b/plugins/fusion-plugin-cli-printing-press/manifest.json @@ -2,5 +2,15 @@ "id": "fusion-plugin-cli-printing-press", "name": "CLI Printing Press", "version": "0.1.0", - "description": "Generate and manage CLIs for external services using cli-printing-press" + "description": "Guided wizard for drafting external service CLI definitions", + "dashboardViews": [ + { + "viewId": "wizard", + "label": "Create Service CLI", + "componentPath": "./dashboard-view", + "icon": "Wand2", + "placement": "primary", + "order": 60 + } + ] } diff --git a/plugins/fusion-plugin-cli-printing-press/package.json b/plugins/fusion-plugin-cli-printing-press/package.json index 94e471f86..ceda51a4b 100644 --- a/plugins/fusion-plugin-cli-printing-press/package.json +++ b/plugins/fusion-plugin-cli-printing-press/package.json @@ -8,6 +8,10 @@ ".": { "types": "./src/index.ts", "import": "./src/index.ts" + }, + "./dashboard-view": { + "types": "./src/dashboard-view.tsx", + "import": "./src/dashboard-view.tsx" } }, "scripts": { @@ -16,10 +20,20 @@ }, "dependencies": { "@fusion/core": "workspace:*", - "@fusion/plugin-sdk": "workspace:*" + "@fusion/dashboard": "workspace:*", + "@fusion/plugin-sdk": "workspace:*", + "express": "^5.1.0", + "lucide-react": "^0.542.0", + "react": "^19.0.0", + "react-dom": "^19.2.4" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/express": "^5.0.5", "@types/node": "^25.5.2", + "@types/react": "^19.0.0", "typescript": "^5.7.0", "vitest": "^3.2.4" } diff --git a/plugins/fusion-plugin-cli-printing-press/src/__tests__/dashboard-view.test.tsx b/plugins/fusion-plugin-cli-printing-press/src/__tests__/dashboard-view.test.tsx new file mode 100644 index 000000000..fee74fa13 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/__tests__/dashboard-view.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CliPrintingPressWizardView } from "../dashboard-view"; + +vi.mock("lucide-react", () => ({ Wand2: () => null })); + +describe("CliPrintingPressWizardView", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ id: "draft-1" }) }))); + }); + + it("walks steps, gates validation, and posts draft", async () => { + const user = userEvent.setup(); + render(); + + const next = screen.getByRole("button", { name: "Next" }); + expect((next as HTMLButtonElement).disabled).toBe(true); + + await user.type(screen.getByLabelText("Name"), "GitHub"); + await user.type(screen.getByLabelText("Slug"), "github"); + await user.type(screen.getByLabelText("Base URL"), "https://api.github.com"); + expect((next as HTMLButtonElement).disabled).toBe(false); + + await user.click(next); + await user.click(screen.getByRole("button", { name: "Next" })); + + await user.type(screen.getByPlaceholderText("Name"), "List Repos"); + await user.type(screen.getByPlaceholderText("/path"), "/user/repos"); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(screen.getByRole("button", { name: "Next" })); + + await user.click(screen.getByRole("button", { name: "Save draft" })); + expect(fetch).toHaveBeenCalledTimes(1); + const [, options] = (fetch as any).mock.calls[0]; + expect(JSON.parse(options.body)).toMatchObject({ name: "GitHub", slug: "github", baseUrl: "https://api.github.com", transport: "http" }); + expect(await screen.findByText(/Saved — draft id draft-1/)).toBeTruthy(); + }); +}); diff --git a/plugins/fusion-plugin-cli-printing-press/src/__tests__/draft-store.test.ts b/plugins/fusion-plugin-cli-printing-press/src/__tests__/draft-store.test.ts new file mode 100644 index 000000000..1d8ee8cd3 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/__tests__/draft-store.test.ts @@ -0,0 +1,25 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createDraftStore } from "../storage/draft-store"; +import type { ServiceDraft } from "../wizard/types"; + +function makeDraft(): ServiceDraft { + const now = new Date().toISOString(); + return { id: "", name: "Demo", slug: "demo", description: "", baseUrl: "https://example.com", transport: "http", endpoints: [{ id: "e1", name: "Ping", method: "GET", path: "/ping" }], credential: { kind: "none" }, createdAt: now, updatedAt: now }; +} + +describe("draft store", () => { + it("creates, lists, gets, and deletes drafts", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-")); + const store = createDraftStore({ rootDir }); + const created = await store.create(makeDraft()); + expect(created.id).toBeTruthy(); + const list = await store.list(); + expect(list).toHaveLength(1); + expect(await store.get(created.id)).toMatchObject({ id: created.id, slug: "demo" }); + await store.delete(created.id); + expect(await store.get(created.id)).toBeNull(); + }); +}); diff --git a/plugins/fusion-plugin-cli-printing-press/src/__tests__/manifest.test.ts b/plugins/fusion-plugin-cli-printing-press/src/__tests__/manifest.test.ts index 2e2da073a..af38de045 100644 --- a/plugins/fusion-plugin-cli-printing-press/src/__tests__/manifest.test.ts +++ b/plugins/fusion-plugin-cli-printing-press/src/__tests__/manifest.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { validatePluginManifest } from "@fusion/core"; import plugin from "../index.js"; import manifestJson from "../../manifest.json" with { type: "json" }; -describe("cli-printing-press plugin stub (FN-3762)", () => { +describe("cli-printing-press plugin", () => { it("registers with the correct manifest id", () => { expect(plugin.manifest.id).toBe("fusion-plugin-cli-printing-press"); }); @@ -13,8 +13,16 @@ describe("cli-printing-press plugin stub (FN-3762)", () => { expect(validation.valid).toBe(true); }); - it("has no routes or dashboardViews in the stub", () => { - expect(plugin.routes).toBeUndefined(); - expect(plugin.dashboardViews).toBeUndefined(); + it("registers the wizard dashboard view", () => { + expect(plugin.dashboardViews).toEqual([ + { + viewId: "wizard", + label: "Create Service CLI", + componentPath: "./dashboard-view", + icon: "Wand2", + placement: "primary", + order: 60, + }, + ]); }); }); diff --git a/plugins/fusion-plugin-cli-printing-press/src/__tests__/validation.test.ts b/plugins/fusion-plugin-cli-printing-press/src/__tests__/validation.test.ts new file mode 100644 index 000000000..14effeb59 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/__tests__/validation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { ServiceDraft } from "../wizard/types"; +import { validateBasics, validateCredentials, validateDraft, validateEndpoints, validateTransport } from "../wizard/validation"; + +function makeDraft(): ServiceDraft { + const now = new Date().toISOString(); + return { id: "", name: "GitHub", slug: "github", description: "", baseUrl: "https://api.github.com", transport: "http", endpoints: [{ id: "e1", name: "List Repos", method: "GET", path: "/user/repos" }], credential: { kind: "apiKey", header: "Authorization", envVar: "GITHUB_TOKEN" }, createdAt: now, updatedAt: now }; +} + +describe("wizard validation", () => { + it("validates basics", () => { + expect(validateBasics(makeDraft()).ok).toBe(true); + expect(validateBasics({ ...makeDraft(), slug: "Bad Slug" }).ok).toBe(false); + }); + it("validates transport", () => { + expect(validateTransport(makeDraft()).ok).toBe(true); + }); + it("validates endpoints", () => { + expect(validateEndpoints(makeDraft()).ok).toBe(true); + expect(validateEndpoints({ ...makeDraft(), endpoints: [] }).ok).toBe(false); + }); + it("validates credentials", () => { + expect(validateCredentials({ kind: "bearerToken", envVar: "TOKEN" }).ok).toBe(true); + expect(validateCredentials({ kind: "bearerToken", envVar: "" }).ok).toBe(false); + }); + it("validates full draft", () => { + expect(validateDraft(makeDraft()).ok).toBe(true); + expect(validateDraft({ ...makeDraft(), baseUrl: "not-url" }).ok).toBe(false); + }); +}); diff --git a/plugins/fusion-plugin-cli-printing-press/src/__tests__/wizard-routes.test.ts b/plugins/fusion-plugin-cli-printing-press/src/__tests__/wizard-routes.test.ts new file mode 100644 index 000000000..4f54e4e8d --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/__tests__/wizard-routes.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createCliPrintingPressRoutes } from "../routes/wizard-routes"; +import type { ServiceDraft } from "../wizard/types"; + +function makeDraft(): ServiceDraft { + const now = new Date().toISOString(); + return { id: "", name: "Demo", slug: "demo", description: "", baseUrl: "https://example.com", transport: "http", endpoints: [{ id: "e1", name: "Ping", method: "GET", path: "/ping" }], credential: { kind: "none" }, createdAt: now, updatedAt: now }; +} + +function route(method: string, path: string) { + const found = createCliPrintingPressRoutes().find((entry) => entry.method === method && entry.path === path); + if (!found) throw new Error(`missing route ${method} ${path}`); + return found; +} + +describe("wizard routes", () => { + it("handles create/get/delete lifecycle", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "cli-printing-press-routes-")); + const ctx = { taskStore: { getRootDir: () => rootDir } } as any; + + const createRes = await route("POST", "/drafts").handler({ params: {}, body: makeDraft() }, ctx); + expect(createRes.status).toBe(201); + const id = (createRes.body as { id: string }).id; + + const getRes = await route("GET", "/drafts/:id").handler({ params: { id } }, ctx); + expect(getRes.status).toBe(200); + + const missRes = await route("GET", "/drafts/:id").handler({ params: { id: "missing" } }, ctx); + expect(missRes.status).toBe(404); + + const invalidRes = await route("POST", "/drafts").handler({ params: {}, body: { ...makeDraft(), slug: "Bad Slug" } }, ctx); + expect(invalidRes.status).toBe(400); + expect((invalidRes.body as { errors: Record }).errors.slug).toBeTruthy(); + + const deleteRes = await route("DELETE", "/drafts/:id").handler({ params: { id } }, ctx); + expect(deleteRes.status).toBe(204); + }); +}); diff --git a/plugins/fusion-plugin-cli-printing-press/src/dashboard-view.css b/plugins/fusion-plugin-cli-printing-press/src/dashboard-view.css new file mode 100644 index 000000000..bc06b8499 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/dashboard-view.css @@ -0,0 +1,43 @@ +.cli-press-wizard { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.cli-press-stepper { + display: flex; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.cli-press-step { + border: 1px solid var(--border); + border-radius: var(--radius-pill); + padding: var(--space-xs) var(--space-md); +} + +.cli-press-step.is-active { + background: var(--todo); + color: var(--accent-text); +} + +.cli-press-endpoint-row { + display: grid; + gap: var(--space-sm); + grid-template-columns: 1fr 1fr 1fr auto; +} + +.cli-press-actions { + display: flex; + gap: var(--space-sm); +} + +@media (max-width: 768px) { + .cli-press-endpoint-row { + grid-template-columns: 1fr; + } + + .cli-press-actions { + flex-direction: column; + } +} diff --git a/plugins/fusion-plugin-cli-printing-press/src/dashboard-view.tsx b/plugins/fusion-plugin-cli-printing-press/src/dashboard-view.tsx new file mode 100644 index 000000000..c9823f951 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/dashboard-view.tsx @@ -0,0 +1,49 @@ +import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; +import { useMemo, useState } from "react"; +import { BasicsStep, CredentialsStep, EndpointsStep, ReviewStep, TransportStep } from "./wizard/steps.js"; +import type { ServiceDraft, WizardStep } from "./wizard/types.js"; +import { validateBasics, validateCredentials, validateEndpoints, validateTransport } from "./wizard/validation.js"; +import "./dashboard-view.css"; + +const STEPS: WizardStep[] = ["basics", "transport", "endpoints", "credentials", "review"]; + +function createInitialDraft(): ServiceDraft { + const now = new Date().toISOString(); + return { id: "", name: "", slug: "", description: "", baseUrl: "", transport: "http", endpoints: [{ id: crypto.randomUUID(), name: "", method: "GET", path: "" }], credential: { kind: "none" }, createdAt: now, updatedAt: now }; +} + +export function CliPrintingPressWizardView({ context: _context }: { context?: PluginDashboardViewContext }) { + const [draft, setDraft] = useState(() => createInitialDraft()); + const [stepIndex, setStepIndex] = useState(0); + const [savedId, setSavedId] = useState(null); + const [error, setError] = useState(null); + + const currentStep = STEPS[stepIndex]; + const currentValidation = useMemo(() => { + if (currentStep === "basics") return validateBasics(draft); + if (currentStep === "transport") return validateTransport(draft); + if (currentStep === "endpoints") return validateEndpoints(draft); + if (currentStep === "credentials") return validateCredentials(draft.credential); + return { ok: true } as const; + }, [currentStep, draft]); + + async function onSave() { + const response = await fetch("/api/plugins/fusion-plugin-cli-printing-press/drafts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(draft) }); + if (!response.ok) { + const body = await response.json().catch(() => ({} as { error?: string; errors?: Record })); + const firstError = body?.errors ? Object.values(body.errors)[0] : undefined; + setError(body?.error ?? firstError ?? "Failed to save draft"); + return; + } + const body = await response.json(); + setSavedId(body.id); + } + + if (savedId) { + return

Saved — draft id {savedId}

List, edit, regenerate, run/test, and runtime exposure land in FN-3764 / FN-3765 / FN-3767.

; + } + + return
{STEPS.map((step, index) => {step})}
{error ?

{error}

: null}{currentStep === "basics" ? setDraft((current) => ({ ...current, ...patch, updatedAt: new Date().toISOString() }))} /> : null}{currentStep === "transport" ? : null}{currentStep === "endpoints" ? setDraft((current) => ({ ...current, endpoints, updatedAt: new Date().toISOString() }))} /> : null}{currentStep === "credentials" ? setDraft((current) => ({ ...current, credential, updatedAt: new Date().toISOString() }))} /> : null}{currentStep === "review" ? : null}
{stepIndex < STEPS.length - 1 ? : }
; +} + +export default CliPrintingPressWizardView; diff --git a/plugins/fusion-plugin-cli-printing-press/src/index.ts b/plugins/fusion-plugin-cli-printing-press/src/index.ts index 35c40d141..a3c7823e6 100644 --- a/plugins/fusion-plugin-cli-printing-press/src/index.ts +++ b/plugins/fusion-plugin-cli-printing-press/src/index.ts @@ -1,14 +1,27 @@ import { definePlugin } from "@fusion/plugin-sdk"; +import { createCliPrintingPressRoutes } from "./routes/wizard-routes.js"; const plugin = definePlugin({ manifest: { id: "fusion-plugin-cli-printing-press", name: "CLI Printing Press", version: "0.1.0", - description: "Generate and manage CLIs for external services using cli-printing-press", + description: "Guided wizard for drafting external service CLI definitions", }, state: "installed", hooks: {}, + routes: createCliPrintingPressRoutes(), + dashboardViews: [ + { + viewId: "wizard", + label: "Create Service CLI", + componentPath: "./dashboard-view", + icon: "Wand2", + placement: "primary", + order: 60, + }, + ], }); export default plugin; +export { CliPrintingPressWizardView } from "./dashboard-view.js"; diff --git a/plugins/fusion-plugin-cli-printing-press/src/routes/wizard-routes.ts b/plugins/fusion-plugin-cli-printing-press/src/routes/wizard-routes.ts new file mode 100644 index 000000000..bb02ae55b --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/routes/wizard-routes.ts @@ -0,0 +1,59 @@ +import type { PluginContext, PluginRouteDefinition, PluginRouteResult } from "@fusion/core"; +import { createDraftStore } from "../storage/draft-store.js"; +import type { ServiceDraft } from "../wizard/types.js"; +import { validateDraft } from "../wizard/validation.js"; + +interface RouteRequest { + params: Record; + body?: unknown; +} + +function asRequest(req: unknown): RouteRequest { return req as RouteRequest; } + +function ok(body: unknown, status = 200): PluginRouteResult { return { status, body }; } + +export function createCliPrintingPressRoutes(): PluginRouteDefinition[] { + return [ + { + method: "POST", + path: "/drafts", + handler: async (req, ctx: PluginContext) => { + const request = asRequest(req); + const draft = request.body as ServiceDraft; + const result = validateDraft(draft); + if (!result.ok) return { status: 400, body: { error: Object.values(result.errors)[0] ?? "Validation failed", errors: result.errors } }; + const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() }); + const created = await store.create(draft); + return ok(created, 201); + }, + }, + { + method: "GET", + path: "/drafts", + handler: async (_req, ctx: PluginContext) => { + const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() }); + return ok(await store.list()); + }, + }, + { + method: "GET", + path: "/drafts/:id", + handler: async (req, ctx: PluginContext) => { + const request = asRequest(req); + const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() }); + const draft = await store.get(request.params.id); + return draft ? ok(draft) : ok({ error: "Draft not found" }, 404); + }, + }, + { + method: "DELETE", + path: "/drafts/:id", + handler: async (req, ctx: PluginContext) => { + const request = asRequest(req); + const store = createDraftStore({ rootDir: ctx.taskStore.getRootDir() }); + await store.delete(request.params.id); + return { status: 204 }; + }, + }, + ]; +} diff --git a/plugins/fusion-plugin-cli-printing-press/src/storage/draft-store.ts b/plugins/fusion-plugin-cli-printing-press/src/storage/draft-store.ts new file mode 100644 index 000000000..ae1ad2147 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/storage/draft-store.ts @@ -0,0 +1,33 @@ +/* Interim storage — replaced by FN-3766's canonical schema. Do not extend without updating that ticket. */ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { ServiceDraft } from "../wizard/types.js"; + +export function createDraftStore({ rootDir }: { rootDir: string }) { + const draftsDir = join(rootDir, ".fusion", "plugins", "cli-printing-press", "drafts"); + + async function ensureDir() { await mkdir(draftsDir, { recursive: true }); } + + return { + async create(input: ServiceDraft) { + await ensureDir(); + const now = new Date().toISOString(); + const draft: ServiceDraft = { ...input, id: input.id || randomUUID(), createdAt: input.createdAt || now, updatedAt: now }; + await writeFile(join(draftsDir, `${draft.id}.json`), JSON.stringify(draft, null, 2), "utf8"); + return draft; + }, + async list() { + await ensureDir(); + const files = await readdir(draftsDir); + const entries = await Promise.all(files.filter((file) => file.endsWith(".json")).map(async (file) => JSON.parse(await readFile(join(draftsDir, file), "utf8")) as ServiceDraft)); + return entries.map(({ id, name, slug, updatedAt }) => ({ id, name, slug, updatedAt })); + }, + async get(id: string) { + try { return JSON.parse(await readFile(join(draftsDir, `${id}.json`), "utf8")) as ServiceDraft; } catch { return null; } + }, + async delete(id: string) { + await rm(join(draftsDir, `${id}.json`), { force: true }); + }, + }; +} diff --git a/plugins/fusion-plugin-cli-printing-press/src/wizard/steps.tsx b/plugins/fusion-plugin-cli-printing-press/src/wizard/steps.tsx new file mode 100644 index 000000000..f067738c1 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/wizard/steps.tsx @@ -0,0 +1,55 @@ +import type { ServiceDraft } from "./types.js"; + +export function BasicsStep({ draft, onChange }: { draft: ServiceDraft; onChange: (patch: Partial) => void }) { + return ( +
+ + onChange({ name: e.target.value })} /> + + onChange({ slug: e.target.value })} /> + + onChange({ description: e.target.value })} /> + + onChange({ baseUrl: e.target.value })} /> +
+ ); +} + +export function TransportStep() { + return

Transport

Other transports land in follow-up tasks.

; +} + +export function EndpointsStep({ draft, onChange }: { draft: ServiceDraft; onChange: (endpoints: ServiceDraft["endpoints"]) => void }) { + return
{draft.endpoints.map((endpoint) =>
onChange(draft.endpoints.map((item) => item.id === endpoint.id ? { ...item, name: e.target.value } : item))} /> onChange(draft.endpoints.map((item) => item.id === endpoint.id ? { ...item, path: e.target.value } : item))} />
) }
; +} + +export function CredentialsStep({ draft, onChange }: { draft: ServiceDraft; onChange: (credential: ServiceDraft["credential"]) => void }) { + const credential = draft.credential; + return ( +
+

OAuth support is deferred to FN-3762 / FN-3766.

+ + + + + + {credential.kind === "apiKey" ? ( + <> + onChange({ kind: "apiKey", header: e.target.value, envVar: credential.envVar })} /> + onChange({ kind: "apiKey", header: credential.header, envVar: e.target.value })} /> + + ) : null} + {credential.kind === "bearerToken" ? onChange({ kind: "bearerToken", envVar: e.target.value })} /> : null} + {credential.kind === "basicAuth" ? ( + <> + onChange({ kind: "basicAuth", usernameEnvVar: e.target.value, passwordEnvVar: credential.passwordEnvVar })} /> + onChange({ kind: "basicAuth", usernameEnvVar: credential.usernameEnvVar, passwordEnvVar: e.target.value })} /> + + ) : null} +
+ ); +} + +export function ReviewStep({ draft }: { draft: ServiceDraft }) { + return
{JSON.stringify(draft, null, 2)}
; +} diff --git a/plugins/fusion-plugin-cli-printing-press/src/wizard/types.ts b/plugins/fusion-plugin-cli-printing-press/src/wizard/types.ts new file mode 100644 index 000000000..91fcc8cf8 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/wizard/types.ts @@ -0,0 +1,30 @@ +export type CredentialPattern = + | { kind: "none" } + | { kind: "apiKey"; header: string; envVar: string } + | { kind: "bearerToken"; envVar: string } + | { kind: "basicAuth"; usernameEnvVar: string; passwordEnvVar: string }; +// TODO(FN-3762/FN-3766): OAuth credential variants. + +export interface ServiceEndpoint { + id: string; + name: string; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + path: string; + summary?: string; + params?: string; +} + +export interface ServiceDraft { + id: string; + name: string; + slug: string; + description: string; + baseUrl: string; + transport: "http"; + endpoints: ServiceEndpoint[]; + credential: CredentialPattern; + createdAt: string; + updatedAt: string; +} + +export type WizardStep = "basics" | "transport" | "endpoints" | "credentials" | "review"; diff --git a/plugins/fusion-plugin-cli-printing-press/src/wizard/validation.ts b/plugins/fusion-plugin-cli-printing-press/src/wizard/validation.ts new file mode 100644 index 000000000..d65fb0bd1 --- /dev/null +++ b/plugins/fusion-plugin-cli-printing-press/src/wizard/validation.ts @@ -0,0 +1,63 @@ +import type { CredentialPattern, ServiceDraft, ServiceEndpoint } from "./types.js"; + +type Ok = { ok: true }; +type Fail = { ok: false; errors: Record }; +export type ValidationResult = Ok | Fail; + +const SLUG_PATTERN = /^[a-z0-9-]+$/; +const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); + +function fail(errors: Record): Fail { + return { ok: false, errors }; +} + +export function validateBasics(draft: ServiceDraft): ValidationResult { + const errors: Record = {}; + if (!draft.name.trim()) errors.name = "Name is required"; + if (!SLUG_PATTERN.test(draft.slug)) errors.slug = "Slug must use lowercase letters, numbers, and single hyphens"; + if (!draft.baseUrl.trim()) { + errors.baseUrl = "Base URL is required"; + } else { + try { new URL(draft.baseUrl); } catch { errors.baseUrl = "Base URL must be a valid URL"; } + } + return Object.keys(errors).length ? fail(errors) : { ok: true }; +} + +export function validateTransport(draft: ServiceDraft): ValidationResult { + return draft.transport === "http" ? { ok: true } : fail({ transport: "Only HTTP transport is supported in v1" }); +} + +function validateEndpoint(endpoint: ServiceEndpoint, index: number): Record { + const errors: Record = {}; + if (!endpoint.name.trim()) errors[`endpoints.${index}.name`] = "Endpoint name is required"; + if (!METHODS.has(endpoint.method)) errors[`endpoints.${index}.method`] = "HTTP method is invalid"; + if (!endpoint.path.trim()) errors[`endpoints.${index}.path`] = "Endpoint path is required"; + return errors; +} + +export function validateEndpoints(draft: ServiceDraft): ValidationResult { + const errors: Record = {}; + if (draft.endpoints.length < 1) errors.endpoints = "At least one endpoint is required"; + draft.endpoints.forEach((endpoint, index) => Object.assign(errors, validateEndpoint(endpoint, index))); + return Object.keys(errors).length ? fail(errors) : { ok: true }; +} + +export function validateCredentials(credential: CredentialPattern): ValidationResult { + const errors: Record = {}; + if (credential.kind === "apiKey") { + if (!credential.header.trim()) errors.header = "Header is required"; + if (!credential.envVar.trim()) errors.envVar = "Environment variable is required"; + } + if (credential.kind === "bearerToken" && !credential.envVar.trim()) errors.envVar = "Environment variable is required"; + if (credential.kind === "basicAuth") { + if (!credential.usernameEnvVar.trim()) errors.usernameEnvVar = "Username env var is required"; + if (!credential.passwordEnvVar.trim()) errors.passwordEnvVar = "Password env var is required"; + } + return Object.keys(errors).length ? fail(errors) : { ok: true }; +} + +export function validateDraft(draft: ServiceDraft): ValidationResult { + const validations = [validateBasics(draft), validateTransport(draft), validateEndpoints(draft), validateCredentials(draft.credential)]; + const errors = validations.filter((r): r is Fail => !r.ok).reduce((acc, item) => ({ ...acc, ...item.errors }), {}); + return Object.keys(errors).length ? fail(errors) : { ok: true }; +} diff --git a/plugins/fusion-plugin-cli-printing-press/tsconfig.json b/plugins/fusion-plugin-cli-printing-press/tsconfig.json index b755b42c3..bec9d43a4 100644 --- a/plugins/fusion-plugin-cli-printing-press/tsconfig.json +++ b/plugins/fusion-plugin-cli-printing-press/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "jsx": "react-jsx" }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["src/**/*.test.ts", "src/**/__tests__/**"] diff --git a/plugins/fusion-plugin-cli-printing-press/vitest.config.ts b/plugins/fusion-plugin-cli-printing-press/vitest.config.ts index 8aace4d04..6ef22dc2d 100644 --- a/plugins/fusion-plugin-cli-printing-press/vitest.config.ts +++ b/plugins/fusion-plugin-cli-printing-press/vitest.config.ts @@ -6,17 +6,13 @@ const maxWorkers = computeMaxWorkers(); export default defineConfig({ resolve: { - alias: [ - { find: "@fusion/core", replacement: fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)) }, - { - find: "@fusion/plugin-sdk", - replacement: fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), - }, - ], + alias: { + "@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)), + "@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), + "@fusion/dashboard": fileURLToPath(new URL("../../packages/dashboard/app/index.ts", import.meta.url)), + }, }, test: { - include: ["src/**/*.test.{ts,tsx}"], - environment: "node", setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))], globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))], pool: "threads", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38d70f2db..f26fb10b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,6 +196,9 @@ importers: '@codemirror/view': specifier: ^6.36.4 version: 6.40.0 + '@fusion-plugin-examples/cli-printing-press': + specifier: workspace:* + version: link:../../plugins/fusion-plugin-cli-printing-press '@fusion-plugin-examples/cursor-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-cursor-runtime @@ -632,13 +635,43 @@ importers: '@fusion/core': specifier: workspace:* version: link:../../packages/core + '@fusion/dashboard': + specifier: workspace:* + version: link:../../packages/dashboard '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk + express: + specifier: ^5.1.0 + version: 5.2.1 + lucide-react: + specifier: ^0.542.0 + version: 0.542.0(react@19.2.4) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) devDependencies: + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/express': + specifier: ^5.0.5 + version: 5.0.6 '@types/node': specifier: ^25.5.2 version: 25.5.2 + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 typescript: specifier: ^5.7.0 version: 5.9.3