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 cedf07c1fe
commit 6769b836f6
24 changed files with 627 additions and 23 deletions

View File

@@ -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:
- `<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",
"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
}
]
}

View File

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

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

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

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",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"jsx": "react-jsx"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]

View File

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