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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
30
plugins/fusion-plugin-cli-printing-press/src/wizard/types.ts
Normal file
30
plugins/fusion-plugin-cli-printing-press/src/wizard/types.ts
Normal 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";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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__/**"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user