feat(FN-3763): add cli printing press wizard plugin

Implements a new `fusion-plugin-cli-printing-press` plugin with a multi-step wizard UI for authoring CLI commands, including draft storage, route registration, and validation — plus integration wiring in the dashboard's plugin view registry. Includes TypeScript build and ESM import fixes for the plu

Fusion-Task-Id: FN-3763
This commit is contained in:
Fusion
2026-05-10 15:01:54 -07:00
committed by gsxdsm
parent 6871c510a4
commit aa031ab601
24 changed files with 627 additions and 23 deletions

View File

@@ -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.

View File

@@ -35,6 +35,12 @@ describe("pluginViewRegistry", () => {
expect(getPluginViewComponent("plugin-b", "missing")).toBeNull(); expect(getPluginViewComponent("plugin-b", "missing")).toBeNull();
}); });
it("resolves cli printing press wizard entries", () => {
const View = lazy(async () => ({ default: () => <div>Wizard</div> }));
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", () => { it("keeps all registered plugin views discoverable by key iteration", () => {
const ViewA = lazy(async () => ({ default: () => <div>A</div> })); const ViewA = lazy(async () => ({ default: () => <div>A</div> }));
const ViewB = lazy(async () => ({ default: () => <div>B</div> })); const ViewB = lazy(async () => ({ default: () => <div>B</div> }));

View File

@@ -8,6 +8,7 @@ import {
const MockDependencyGraphDashboardView = () => createElement("div", { "data-testid": "dep-graph-view" }); const MockDependencyGraphDashboardView = () => createElement("div", { "data-testid": "dep-graph-view" });
const MockRoadmapDashboardView = () => createElement("div", { "data-testid": "roadmap-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", () => ({ vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({
DependencyGraphDashboardView: (...args: unknown[]) => MockDependencyGraphDashboardView(...args), DependencyGraphDashboardView: (...args: unknown[]) => MockDependencyGraphDashboardView(...args),
@@ -17,17 +18,22 @@ vi.mock("@fusion-plugin-examples/roadmap/dashboard-view", () => ({
RoadmapDashboardView: (...args: unknown[]) => MockRoadmapDashboardView(...args), RoadmapDashboardView: (...args: unknown[]) => MockRoadmapDashboardView(...args),
})); }));
vi.mock("@fusion-plugin-examples/cli-printing-press/dashboard-view", () => ({
CliPrintingPressWizardView: (...args: unknown[]) => MockCliPrintingPressWizardView(...args),
}));
describe("registerBundledPluginViews", () => { describe("registerBundledPluginViews", () => {
beforeEach(() => { beforeEach(() => {
__test_clearPluginViewRegistry(); __test_clearPluginViewRegistry();
__test_resetBundledPluginViewRegistration(); __test_resetBundledPluginViewRegistration();
}); });
it("registers dependency graph and roadmap bundled views", () => { it("registers dependency graph, roadmap, and cli printing press bundled views", () => {
registerBundledPluginViews(); registerBundledPluginViews();
expect(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBeTruthy(); expect(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBeTruthy();
expect(getPluginViewComponent("roadmap-planner", "roadmaps")).toBeTruthy(); expect(getPluginViewComponent("roadmap-planner", "roadmaps")).toBeTruthy();
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBeTruthy();
}); });
it("is idempotent when called more than once", () => { 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("fusion-plugin-dependency-graph", "graph")).toBe(true);
expect(isPluginViewRegistered("roadmap-planner", "roadmaps")).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 // Unknown plugin/view should not be registered
expect(isPluginViewRegistered("unknown-plugin", "unknown")).toBe(false); expect(isPluginViewRegistered("unknown-plugin", "unknown")).toBe(false);
}); });

View File

@@ -37,6 +37,18 @@ async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> {
return { default: component as 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<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
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 { export function registerBundledPluginViews(): void {
if (registered) return; if (registered) return;
registered = true; registered = true;
@@ -52,6 +64,12 @@ export function registerBundledPluginViews(): void {
"roadmaps", "roadmaps",
lazy(loadRoadmapView), lazy(loadRoadmapView),
); );
registerPluginView(
"fusion-plugin-cli-printing-press",
"wizard",
lazy(loadCliPrintingPressWizardView),
);
} }
export function __test_resetBundledPluginViewRegistration(): void { export function __test_resetBundledPluginViewRegistration(): void {

View File

@@ -79,6 +79,7 @@
"@fusion-plugin-examples/openclaw-runtime": "workspace:*", "@fusion-plugin-examples/openclaw-runtime": "workspace:*",
"@fusion-plugin-examples/droid-runtime": "workspace:*", "@fusion-plugin-examples/droid-runtime": "workspace:*",
"@fusion-plugin-examples/cursor-runtime": "workspace:*", "@fusion-plugin-examples/cursor-runtime": "workspace:*",
"@fusion-plugin-examples/cli-printing-press": "workspace:*",
"@fusion-plugin-examples/paperclip-runtime": "workspace:*", "@fusion-plugin-examples/paperclip-runtime": "workspace:*",
"@fusion/core": "workspace:*", "@fusion/core": "workspace:*",
"@fusion/engine": "workspace:*", "@fusion/engine": "workspace:*",

View File

@@ -1,9 +1,33 @@
# fusion-plugin-cli-printing-press # 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:
- `<projectRoot>/.fusion/plugins/cli-printing-press/drafts/<id>.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.

View File

@@ -2,5 +2,15 @@
"id": "fusion-plugin-cli-printing-press", "id": "fusion-plugin-cli-printing-press",
"name": "CLI Printing Press", "name": "CLI Printing Press",
"version": "0.1.0", "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
}
]
} }

View File

@@ -8,6 +8,10 @@
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"import": "./src/index.ts" "import": "./src/index.ts"
},
"./dashboard-view": {
"types": "./src/dashboard-view.tsx",
"import": "./src/dashboard-view.tsx"
} }
}, },
"scripts": { "scripts": {
@@ -16,10 +20,20 @@
}, },
"dependencies": { "dependencies": {
"@fusion/core": "workspace:*", "@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": { "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/node": "^25.5.2",
"@types/react": "^19.0.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }

View File

@@ -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(<CliPrintingPressWizardView />);
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();
});
});

View File

@@ -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();
});
});

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from "vitest"; import { describe, expect, it } from "vitest";
import { validatePluginManifest } from "@fusion/core"; import { validatePluginManifest } from "@fusion/core";
import plugin from "../index.js"; import plugin from "../index.js";
import manifestJson from "../../manifest.json" with { type: "json" }; 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", () => { it("registers with the correct manifest id", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-cli-printing-press"); 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); expect(validation.valid).toBe(true);
}); });
it("has no routes or dashboardViews in the stub", () => { it("registers the wizard dashboard view", () => {
expect(plugin.routes).toBeUndefined(); expect(plugin.dashboardViews).toEqual([
expect(plugin.dashboardViews).toBeUndefined(); {
viewId: "wizard",
label: "Create Service CLI",
componentPath: "./dashboard-view",
icon: "Wand2",
placement: "primary",
order: 60,
},
]);
}); });
}); });

View File

@@ -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);
});
});

View File

@@ -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<string, string> }).errors.slug).toBeTruthy();
const deleteRes = await route("DELETE", "/drafts/:id").handler({ params: { id } }, ctx);
expect(deleteRes.status).toBe(204);
});
});

View File

@@ -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;
}
}

View File

@@ -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<ServiceDraft>(() => createInitialDraft());
const [stepIndex, setStepIndex] = useState(0);
const [savedId, setSavedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<string, string> }));
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 <section className="card"><h2>Saved draft id {savedId}</h2><p>List, edit, regenerate, run/test, and runtime exposure land in FN-3764 / FN-3765 / FN-3767.</p></section>;
}
return <section className="cli-press-wizard"><div className="cli-press-stepper">{STEPS.map((step, index) => <span className={`cli-press-step${index === stepIndex ? " is-active" : ""}`} key={step}>{step}</span>)}</div>{error ? <p className="form-error">{error}</p> : null}{currentStep === "basics" ? <BasicsStep draft={draft} onChange={(patch) => setDraft((current) => ({ ...current, ...patch, updatedAt: new Date().toISOString() }))} /> : null}{currentStep === "transport" ? <TransportStep /> : null}{currentStep === "endpoints" ? <EndpointsStep draft={draft} onChange={(endpoints) => setDraft((current) => ({ ...current, endpoints, updatedAt: new Date().toISOString() }))} /> : null}{currentStep === "credentials" ? <CredentialsStep draft={draft} onChange={(credential) => setDraft((current) => ({ ...current, credential, updatedAt: new Date().toISOString() }))} /> : null}{currentStep === "review" ? <ReviewStep draft={draft} /> : null}<div className="cli-press-actions"><button className="btn" onClick={() => setDraft(createInitialDraft())}>Cancel</button><button className="btn" disabled={stepIndex === 0} onClick={() => setStepIndex((value) => Math.max(0, value - 1))}>Back</button>{stepIndex < STEPS.length - 1 ? <button className="btn btn-primary" disabled={!currentValidation.ok} onClick={() => setStepIndex((value) => Math.min(STEPS.length - 1, value + 1))}>Next</button> : <button className="btn btn-primary" onClick={() => void onSave()}>Save draft</button>}</div></section>;
}
export default CliPrintingPressWizardView;

View File

@@ -1,14 +1,27 @@
import { definePlugin } from "@fusion/plugin-sdk"; import { definePlugin } from "@fusion/plugin-sdk";
import { createCliPrintingPressRoutes } from "./routes/wizard-routes.js";
const plugin = definePlugin({ const plugin = definePlugin({
manifest: { manifest: {
id: "fusion-plugin-cli-printing-press", id: "fusion-plugin-cli-printing-press",
name: "CLI Printing Press", name: "CLI Printing Press",
version: "0.1.0", 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", state: "installed",
hooks: {}, hooks: {},
routes: createCliPrintingPressRoutes(),
dashboardViews: [
{
viewId: "wizard",
label: "Create Service CLI",
componentPath: "./dashboard-view",
icon: "Wand2",
placement: "primary",
order: 60,
},
],
}); });
export default plugin; export default plugin;
export { CliPrintingPressWizardView } from "./dashboard-view.js";

View File

@@ -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<string, string>;
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 };
},
},
];
}

View File

@@ -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 });
},
};
}

View File

@@ -0,0 +1,55 @@
import type { ServiceDraft } from "./types.js";
export function BasicsStep({ draft, onChange }: { draft: ServiceDraft; onChange: (patch: Partial<ServiceDraft>) => void }) {
return (
<div className="form-group">
<label>Name</label>
<input aria-label="Name" className="input" value={draft.name} onChange={(e) => onChange({ name: e.target.value })} />
<label>Slug</label>
<input aria-label="Slug" className="input" value={draft.slug} onChange={(e) => onChange({ slug: e.target.value })} />
<label>Description</label>
<input aria-label="Description" className="input" value={draft.description} onChange={(e) => onChange({ description: e.target.value })} />
<label>Base URL</label>
<input aria-label="Base URL" className="input" value={draft.baseUrl} onChange={(e) => onChange({ baseUrl: e.target.value })} />
</div>
);
}
export function TransportStep() {
return <div className="card"><p>Transport</p><input className="input" value="HTTP" readOnly disabled /><p>Other transports land in follow-up tasks.</p></div>;
}
export function EndpointsStep({ draft, onChange }: { draft: ServiceDraft; onChange: (endpoints: ServiceDraft["endpoints"]) => void }) {
return <div className="cli-press-endpoint-list">{draft.endpoints.map((endpoint) => <div className="card cli-press-endpoint-row" key={endpoint.id}><input className="input" value={endpoint.name} placeholder="Name" onChange={(e) => onChange(draft.endpoints.map((item) => item.id === endpoint.id ? { ...item, name: e.target.value } : item))} /><select className="input" value={endpoint.method} onChange={(e) => onChange(draft.endpoints.map((item) => item.id === endpoint.id ? { ...item, method: e.target.value as typeof endpoint.method } : item))}><option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option></select><input className="input" value={endpoint.path} placeholder="/path" onChange={(e) => onChange(draft.endpoints.map((item) => item.id === endpoint.id ? { ...item, path: e.target.value } : item))} /><button className="btn btn-danger" onClick={() => onChange(draft.endpoints.filter((item) => item.id !== endpoint.id))}>Remove</button></div>) }<button className="btn" onClick={() => onChange([...draft.endpoints, { id: crypto.randomUUID(), name: "", method: "GET", path: "" }])}>Add endpoint</button></div>;
}
export function CredentialsStep({ draft, onChange }: { draft: ServiceDraft; onChange: (credential: ServiceDraft["credential"]) => void }) {
const credential = draft.credential;
return (
<div className="form-group">
<p className="card">OAuth support is deferred to FN-3762 / FN-3766.</p>
<label><input type="radio" checked={credential.kind === "none"} onChange={() => onChange({ kind: "none" })} />None</label>
<label><input type="radio" checked={credential.kind === "apiKey"} onChange={() => onChange({ kind: "apiKey", header: "", envVar: "" })} />API Key</label>
<label><input type="radio" checked={credential.kind === "bearerToken"} onChange={() => onChange({ kind: "bearerToken", envVar: "" })} />Bearer Token</label>
<label><input type="radio" checked={credential.kind === "basicAuth"} onChange={() => onChange({ kind: "basicAuth", usernameEnvVar: "", passwordEnvVar: "" })} />Basic Auth</label>
{credential.kind === "apiKey" ? (
<>
<input className="input" value={credential.header} placeholder="X-Api-Key" onChange={(e) => onChange({ kind: "apiKey", header: e.target.value, envVar: credential.envVar })} />
<input className="input" value={credential.envVar} placeholder="SERVICE_API_KEY" onChange={(e) => onChange({ kind: "apiKey", header: credential.header, envVar: e.target.value })} />
</>
) : null}
{credential.kind === "bearerToken" ? <input className="input" value={credential.envVar} placeholder="SERVICE_TOKEN" onChange={(e) => onChange({ kind: "bearerToken", envVar: e.target.value })} /> : null}
{credential.kind === "basicAuth" ? (
<>
<input className="input" value={credential.usernameEnvVar} placeholder="SERVICE_USER" onChange={(e) => onChange({ kind: "basicAuth", usernameEnvVar: e.target.value, passwordEnvVar: credential.passwordEnvVar })} />
<input className="input" value={credential.passwordEnvVar} placeholder="SERVICE_PASS" onChange={(e) => onChange({ kind: "basicAuth", usernameEnvVar: credential.usernameEnvVar, passwordEnvVar: e.target.value })} />
</>
) : null}
</div>
);
}
export function ReviewStep({ draft }: { draft: ServiceDraft }) {
return <pre className="card">{JSON.stringify(draft, null, 2)}</pre>;
}

View File

@@ -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";

View File

@@ -0,0 +1,63 @@
import type { CredentialPattern, ServiceDraft, ServiceEndpoint } from "./types.js";
type Ok = { ok: true };
type Fail = { ok: false; errors: Record<string, string> };
export type ValidationResult = Ok | Fail;
const SLUG_PATTERN = /^[a-z0-9-]+$/;
const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
function fail(errors: Record<string, string>): Fail {
return { ok: false, errors };
}
export function validateBasics(draft: ServiceDraft): ValidationResult {
const errors: Record<string, string> = {};
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<string, string> {
const errors: Record<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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 };
}

View File

@@ -2,7 +2,8 @@
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src",
"jsx": "react-jsx"
}, },
"include": ["src/**/*.ts", "src/**/*.tsx"], "include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"] "exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]

View File

@@ -6,17 +6,13 @@ const maxWorkers = computeMaxWorkers();
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
alias: [ alias: {
{ find: "@fusion/core", replacement: fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)) }, "@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)),
find: "@fusion/plugin-sdk", "@fusion/dashboard": fileURLToPath(new URL("../../packages/dashboard/app/index.ts", import.meta.url)),
replacement: fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), },
},
],
}, },
test: { test: {
include: ["src/**/*.test.{ts,tsx}"],
environment: "node",
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))], 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))], globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads", pool: "threads",

33
pnpm-lock.yaml generated
View File

@@ -196,6 +196,9 @@ importers:
'@codemirror/view': '@codemirror/view':
specifier: ^6.36.4 specifier: ^6.36.4
version: 6.40.0 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': '@fusion-plugin-examples/cursor-runtime':
specifier: workspace:* specifier: workspace:*
version: link:../../plugins/fusion-plugin-cursor-runtime version: link:../../plugins/fusion-plugin-cursor-runtime
@@ -632,13 +635,43 @@ importers:
'@fusion/core': '@fusion/core':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/core version: link:../../packages/core
'@fusion/dashboard':
specifier: workspace:*
version: link:../../packages/dashboard
'@fusion/plugin-sdk': '@fusion/plugin-sdk':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/plugin-sdk 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: 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': '@types/node':
specifier: ^25.5.2 specifier: ^25.5.2
version: 25.5.2 version: 25.5.2
'@types/react':
specifier: ^19.0.0
version: 19.2.14
typescript: typescript:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3