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