From b86fd245f402dc5bb47edd091d21115efeea2b6b Mon Sep 17 00:00:00 2001
From: olliethedev <5933733+olliethedev@users.noreply.github.com>
Date: Tue, 1 Sep 2026 16:40:41 -0400
Subject: [PATCH 1/3] docs: add plugin evaluator decision surfaces (#262)
---
docs/content/docs/plugins/blog.mdx | 14 +-
docs/content/docs/plugins/form-builder.mdx | 21 +-
docs/content/docs/plugins/index.mdx | 44 +-
docs/content/docs/plugins/open-api.mdx | 21 +-
docs/package.json | 7 +-
docs/src/components/plugin-evaluator.tsx | 482 ++++++++++++++++++
packages/cli/src/lib.ts | 2 +-
packages/cli/src/meta.ts | 2 +-
packages/cli/src/plugin-meta.ts | 2 +
.../src/utils/__tests__/plugin-meta.test.ts | 83 +++
packages/cli/src/utils/constants.ts | 6 +
packages/cli/src/utils/plugin-decision.ts | 129 +++++
pnpm-lock.yaml | 3 +
13 files changed, 742 insertions(+), 74 deletions(-)
create mode 100644 docs/src/components/plugin-evaluator.tsx
create mode 100644 packages/cli/src/plugin-meta.ts
create mode 100644 packages/cli/src/utils/__tests__/plugin-meta.test.ts
create mode 100644 packages/cli/src/utils/plugin-decision.ts
diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx
index 79d592a57..128ce9c5b 100644
--- a/docs/content/docs/plugins/blog.mdx
+++ b/docs/content/docs/plugins/blog.mdx
@@ -5,19 +5,9 @@ description: Content management, editor, drafts, publishing, SEO and more
import { Tabs, Tab } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";
-import Image from "next/image";
+import { PluginEvaluatorHeader } from "@/components/plugin-evaluator";
-import blogProof from "../../../assets/product-proof/blog-proof.webp";
-
-
-
-Blog is the canonical full-stack proof: routes, backend behavior, client UI, and a visible published result arrive as one plugin.
-
-[View interactive demo →](https://www.better-stack.ai/playground?plugins=blog&view=preview)
+
## Installation
diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx
index 7c60dfd76..9dc116efa 100644
--- a/docs/content/docs/plugins/form-builder.mdx
+++ b/docs/content/docs/plugins/form-builder.mdx
@@ -5,26 +5,9 @@ description: Visual drag-and-drop form builder with JSON Schema storage and publ
import { Tabs, Tab } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";
-import Image from "next/image";
+import { PluginEvaluatorHeader } from "@/components/plugin-evaluator";
-import formBuilderProof from "../../../assets/product-proof/form-builder-proof.webp";
-
-
-
-The Form Builder plugin provides a visual drag-and-drop form creation interface where administrators can create forms that are serialized and stored as JSON Schema. This is distinct from the CMS plugin - while CMS uses developer-defined Zod schemas, Form Builder allows non-technical administrators to create forms dynamically.
-
-**Key Features:**
-- **Visual Form Builder** - Drag-and-drop interface for creating forms with various field types
-- **JSON Schema Storage** - Forms are serialized to JSON Schema for database persistence
-- **Public Form Rendering** - Render forms by slug on the frontend with automatic validation
-- **Submission Tracking** - Store and view form submissions with IP address and user agent logging
-- **Backend Hooks** - Typed lifecycle hooks for domain validation, rate limiting, and integrations
-
-[View interactive demo →](https://www.better-stack.ai/playground?plugins=form-builder&view=preview)
+
## Installation
diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx
index 2bf5655bf..ead7c91b9 100644
--- a/docs/content/docs/plugins/index.mdx
+++ b/docs/content/docs/plugins/index.mdx
@@ -4,7 +4,8 @@ description: Available plugins and features for BTST
---
import { Card, Cards } from "fumadocs-ui/components/card";
-import { BookOpen, Database, Hammer, Bot, FileText, FileCode, Route, Layout, Columns3, MessageSquare, ImageIcon, ShieldCheck } from "lucide-react";
+import { Database, Hammer, Bot, Route, Layout, Columns3, MessageSquare, ImageIcon, ShieldCheck } from "lucide-react";
+import { PluginCatalogCard } from "@/components/plugin-evaluator";
BTST ships installable capabilities with different boundaries. Full-stack
@@ -16,13 +17,20 @@ Every capability below is released and installable. Choose a page to see its
actual payload and prerequisites; the catalog does not mix roadmap ideas into
the available inventory.
+## Representative decision guides
+
+These three entries establish the decision pattern for a complete feature, a
+workflow-heavy feature, and an intentionally one-sided plugin.
+
+
+
+## More released plugins
+
- }
- description="Content management, editor, drafts, publishing, SEO, RSS feeds."
- />
}
description="Headless CMS with custom content types, Zod schemas, and auto-generated forms."
/>
- }
- description="Dynamic form builder with drag-and-drop editor, submissions, and validation."
- />
}
description="Media library with uploads, folders, picker UI, URL registration, and reusable image inputs."
/>
- }
- description="Auto-generated API documentation with interactive Scalar UI."
- />
+
+## Versioned technical metadata
+
+Build-time consumers can import `PLUGINS` and the `PluginDecisionMeta` type from
+`@btst/codegen/meta`. The contract carries technical inventory such as topology,
+relationship, maturity, dependencies, maintained integrations, and canonical
+docs, demo, and source identifiers. Audience copy, workflow wording, and calls
+to action remain presentation-owned.
+
+
diff --git a/docs/content/docs/plugins/open-api.mdx b/docs/content/docs/plugins/open-api.mdx
index 54e4103c9..3e1c22f6f 100644
--- a/docs/content/docs/plugins/open-api.mdx
+++ b/docs/content/docs/plugins/open-api.mdx
@@ -5,26 +5,9 @@ description: Auto-generated API documentation with interactive Scalar UI
import { Tabs, Tab } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";
-import Image from "next/image";
+import { PluginEvaluatorHeader } from "@/components/plugin-evaluator";
-import openApiProof from "../../../assets/product-proof/openapi-proof.webp";
-
-
-
-The OpenAPI plugin automatically generates OpenAPI 3.1 documentation for all your BTST plugins. It provides both a JSON schema endpoint and an interactive API reference UI powered by [Scalar](https://scalar.com/).
-
-## Features
-
-- **Automatic Schema Generation** - Traverses all registered plugins and extracts endpoint metadata
-- **OpenAPI 3.1 Compliant** - Generates valid OpenAPI 3.1 schemas from Zod definitions
-- **Authorization Metadata** - Documents explicit public access or the stable permission ID enforced by each operation
-- **Interactive UI** - Beautiful API reference page powered by Scalar
-- **Multiple Themes** - Choose from 10+ Scalar themes to match your brand
-- **Zero Configuration** - Works out of the box with sensible defaults
+
## Installation
diff --git a/docs/package.json b/docs/package.json
index ce630d1df..2ed9a9e5f 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -3,13 +3,14 @@
"version": "0.0.0",
"private": true,
"scripts": {
- "build": "next build",
- "build:vercel": "next build",
- "dev": "next dev",
+ "build": "pnpm --filter @btst/codegen build && next build",
+ "build:vercel": "pnpm --filter @btst/codegen build && next build",
+ "dev": "pnpm --filter @btst/codegen build && next dev",
"start": "next start",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
+ "@btst/codegen": "workspace:*",
"@btst/stack": "workspace:*",
"@vercel/analytics": "^1.6.1",
"framer-motion": "^12.19.1",
diff --git a/docs/src/components/plugin-evaluator.tsx b/docs/src/components/plugin-evaluator.tsx
new file mode 100644
index 000000000..a74444e57
--- /dev/null
+++ b/docs/src/components/plugin-evaluator.tsx
@@ -0,0 +1,482 @@
+import { ArrowRight, ExternalLink } from "lucide-react";
+import Image, { type StaticImageData } from "next/image";
+import Link from "next/link";
+import type { ReactNode } from "react";
+import blogProof from "../../assets/product-proof/blog-proof.webp";
+import formBuilderProof from "../../assets/product-proof/form-builder-proof.webp";
+import productProofManifest from "../../assets/product-proof/manifest.json";
+import openApiProof from "../../assets/product-proof/openapi-proof.webp";
+import { PLUGINS } from "@btst/codegen/meta";
+
+type RepresentativePluginKey = "blog" | "form-builder" | "open-api";
+type PluginDecisionMeta = NonNullable<
+ (typeof PLUGINS)[number]["decision"]
+>;
+type DecisionActionTarget = "demo" | "docs" | "installation" | "workflow";
+
+interface WorkflowStepCopy {
+ label: string;
+ detail: string;
+}
+
+interface DecisionActionCopy {
+ label: string;
+ target: DecisionActionTarget;
+}
+
+interface ResolvedDecisionAction extends DecisionActionCopy {
+ href: string;
+ external: boolean;
+}
+
+interface RepresentativeCopy {
+ outcome: string;
+ audience: string;
+ ownership: string;
+ workflow: readonly WorkflowStepCopy[];
+ catalogAction: DecisionActionCopy;
+ actions: readonly [DecisionActionCopy, DecisionActionCopy];
+}
+
+const representativeCopy = {
+ blog: {
+ outcome:
+ "Publish and manage a content section inside the React application you already run.",
+ audience:
+ "React teams that need editorial workflows and public, indexable content without adopting a separate hosted CMS.",
+ ownership:
+ "Posts stay in your database, routes run in your application, and ejected Blog views become editable application code.",
+ workflow: [
+ {
+ label: "Register",
+ detail: "Add the Blog backend and client halves to the existing stack.",
+ },
+ {
+ label: "Write",
+ detail: "Create and edit drafts with the supplied authoring routes.",
+ },
+ {
+ label: "Store",
+ detail:
+ "Persist posts and tags through your selected database adapter.",
+ },
+ {
+ label: "Publish",
+ detail:
+ "Serve the SSR-aware post route with metadata and sitemap output.",
+ },
+ ],
+ catalogAction: { label: "Explore Blog", target: "docs" },
+ actions: [
+ { label: "View live Blog", target: "demo" },
+ { label: "Install Blog", target: "installation" },
+ ],
+ },
+ "form-builder": {
+ outcome:
+ "Let operators design forms visually, publish them inside your app, and collect validated submissions.",
+ audience:
+ "Product teams that need runtime-created forms rather than developer-defined CMS content models.",
+ ownership:
+ "Form schemas and submissions stay in your database; public rendering, field overrides, hooks, and deployment remain in your application.",
+ workflow: [
+ {
+ label: "Build",
+ detail:
+ "Arrange fields in the visual editor and check the live preview.",
+ },
+ {
+ label: "Store schema",
+ detail: "Save the generated JSON Schema with the form record.",
+ },
+ {
+ label: "Render",
+ detail: "Mount FormRenderer on a public route owned by your app.",
+ },
+ {
+ label: "Collect",
+ detail:
+ "Validate submitted data against the schema and store the record.",
+ },
+ ],
+ catalogAction: { label: "Explore Form Builder", target: "docs" },
+ actions: [
+ { label: "Understand the workflow", target: "workflow" },
+ { label: "Install Form Builder", target: "installation" },
+ ],
+ },
+ "open-api": {
+ outcome:
+ "Expose an OpenAPI 3.1 contract for registered BTST routes and, when useful, an interactive API reference.",
+ audience:
+ "Backend teams that need machine-readable API documentation without adding a matching BTST client plugin.",
+ ownership:
+ "The schema stays at your backend base path plus the fixed /open-api/schema suffix. The optional reference defaults to /reference, title and version have defaults, and your deployment and access boundary remain yours.",
+ workflow: [
+ {
+ label: "Register",
+ detail:
+ "Add the backend-only plugin to the existing BTST backend stack.",
+ },
+ {
+ label: "Inspect",
+ detail: "Read registered endpoint metadata and Zod request schemas.",
+ },
+ {
+ label: "Generate 3.1",
+ detail: "Serve a deterministic OpenAPI 3.1 document as JSON.",
+ },
+ {
+ label: "Optionally render",
+ detail: "Keep JSON only or expose the Scalar reference page.",
+ },
+ ],
+ catalogAction: { label: "Explore OpenAPI", target: "docs" },
+ actions: [
+ { label: "Inspect the output", target: "workflow" },
+ { label: "Install OpenAPI", target: "installation" },
+ ],
+ },
+} as const satisfies Record;
+
+const representativeAssets = {
+ blog: { file: "blog-proof.webp", image: blogProof },
+ "form-builder": {
+ file: "form-builder-proof.webp",
+ image: formBuilderProof,
+ },
+ "open-api": { file: "openapi-proof.webp", image: openApiProof },
+} as const satisfies Record<
+ RepresentativePluginKey,
+ { file: string; image: StaticImageData }
+>;
+
+function getRepresentativePlugin(key: RepresentativePluginKey) {
+ const plugin = PLUGINS.find((candidate) => candidate.key === key);
+ if (!plugin?.decision) {
+ throw new Error(`Missing evaluator metadata for ${key}`);
+ }
+ return { plugin, decision: plugin.decision, copy: representativeCopy[key] };
+}
+
+function getProofAsset(key: RepresentativePluginKey) {
+ const asset = representativeAssets[key];
+ const copy = productProofManifest.assets.find(
+ (candidate) => candidate.file === asset.file,
+ );
+ if (!copy) throw new Error(`Missing product-proof manifest entry for ${key}`);
+ return { ...asset, alt: copy.alt, caption: copy.caption };
+}
+
+function DecisionLink({
+ action,
+ className,
+ children,
+}: {
+ action: ResolvedDecisionAction;
+ className: string;
+ children: ReactNode;
+}) {
+ return action.external ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ );
+}
+
+function TopologyBadges({ decision }: { decision: PluginDecisionMeta }) {
+ return (
+
+
+ {decision.topology}
+
+ {decision.relationship ? (
+
+ {decision.relationship}
+
+ ) : null}
+
+ Released · {decision.releaseStatus}
+
+
+ );
+}
+
+function FactBlock({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+
{children}
+
+ );
+}
+
+function FactList({ items }: { items: readonly string[] }) {
+ return (
+
+ {items.map((item) => (
+
+
+ {item}
+
+ ))}
+
+ );
+}
+
+function WorkflowStrip({
+ id,
+ steps,
+}: {
+ id: string;
+ steps: readonly WorkflowStepCopy[];
+}) {
+ return (
+
+
+
+ From registration to result
+
+
+ A semantic workflow, not a setup shortcut
+
+
+
+ {steps.map((step, index) => (
+
+
+
+ {index + 1}
+
+ {step.label}
+
+
+ {step.detail}
+
+
+ ))}
+
+
+ );
+}
+
+function resolveAction(
+ key: RepresentativePluginKey,
+ decision: PluginDecisionMeta,
+ action: DecisionActionCopy,
+): ResolvedDecisionAction {
+ switch (action.target) {
+ case "demo":
+ if (!decision.demoPath) {
+ throw new Error(`Missing demo identifier for ${key}`);
+ }
+ return { ...action, href: decision.demoPath, external: true };
+ case "docs":
+ return { ...action, href: decision.docsPath, external: false };
+ case "installation":
+ return { ...action, href: "#installation", external: false };
+ case "workflow":
+ return { ...action, href: `#${key}-workflow`, external: false };
+ }
+}
+
+export function PluginEvaluatorHeader({
+ pluginKey,
+}: {
+ pluginKey: RepresentativePluginKey;
+}) {
+ const { decision, copy } = getRepresentativePlugin(pluginKey);
+ const proof = getProofAsset(pluginKey);
+ const actions = copy.actions.map((action) =>
+ resolveAction(pluginKey, decision, action),
+ );
+
+ return (
+
+
+
+
+
+ Best for
+
+
+ {copy.audience}
+
+
+ {copy.outcome}
+
+
+ {actions.map((action, index) => (
+
+ {action.label}
+ {action.external ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ {proof.caption}
+
+
+
+
+
+
+
+
+
+
+
+
+ {copy.ownership}
+
+
+
+ Maintained: {" "}
+ {decision.supportedFrameworks.join(", ")}.
+
+
+ Requires: {" "}
+ {decision.dependencies.join("; ")}.
+
+
+ External services: {" "}
+ {decision.externalServices.length > 0
+ ? `${decision.externalServices.join("; ")}.`
+ : "None required."}
+
+
+
+
+
+
+ );
+}
+
+export function PluginCatalogCard({
+ pluginKey,
+}: {
+ pluginKey: RepresentativePluginKey;
+}) {
+ const { plugin, decision, copy } = getRepresentativePlugin(pluginKey);
+ const proof = getProofAsset(pluginKey);
+ const catalogAction = resolveAction(
+ pluginKey,
+ decision,
+ copy.catalogAction,
+ );
+
+ return (
+
+
+
+
+
+
+ {plugin.label}
+
+
+
+ {copy.outcome}
+
+
+
+
+ Requires
+
+
+ {decision.dependencies.join("; ")}
+
+
+
+
+ Maintained paths
+
+
+ {decision.supportedFrameworks.join(" · ")}
+
+
+
+
+ {catalogAction.label}
+ {catalogAction.external ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/cli/src/lib.ts b/packages/cli/src/lib.ts
index 4e2509b68..482e51471 100644
--- a/packages/cli/src/lib.ts
+++ b/packages/cli/src/lib.ts
@@ -22,5 +22,5 @@ export type {
FileWritePlanItem,
ScaffoldPlan,
} from "./types";
-export type { PluginMeta, AdapterMeta } from "./utils/constants";
+export type * from "./plugin-meta";
export type { SeedRouteFile } from "./utils/seed-plan";
diff --git a/packages/cli/src/meta.ts b/packages/cli/src/meta.ts
index 2029007d9..5389b0bea 100644
--- a/packages/cli/src/meta.ts
+++ b/packages/cli/src/meta.ts
@@ -12,5 +12,5 @@ export type {
FileWritePlanItem,
ScaffoldPlan,
} from "./types";
-export type { PluginMeta, AdapterMeta } from "./utils/constants";
+export type * from "./plugin-meta";
export type { SeedRouteFile } from "./utils/seed-plan";
diff --git a/packages/cli/src/plugin-meta.ts b/packages/cli/src/plugin-meta.ts
new file mode 100644
index 000000000..e77f19cd4
--- /dev/null
+++ b/packages/cli/src/plugin-meta.ts
@@ -0,0 +1,2 @@
+export type { AdapterMeta, PluginMeta } from "./utils/constants";
+export type { PluginDecisionMeta } from "./utils/plugin-decision";
diff --git a/packages/cli/src/utils/__tests__/plugin-meta.test.ts b/packages/cli/src/utils/__tests__/plugin-meta.test.ts
new file mode 100644
index 000000000..dc2884ebe
--- /dev/null
+++ b/packages/cli/src/utils/__tests__/plugin-meta.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import { PLUGINS } from "../constants";
+
+const maintainedFrameworks = [
+ "Next.js 15+ App Router",
+ "React Router v7",
+ "TanStack Start",
+] as const;
+
+function representativePlugin(key: "blog" | "form-builder" | "open-api") {
+ const plugin = PLUGINS.find((candidate) => candidate.key === key);
+ if (!plugin) throw new Error(`Missing plugin metadata for ${key}`);
+ if (!plugin.decision) throw new Error(`Missing decision metadata for ${key}`);
+ return { plugin, decision: plugin.decision };
+}
+
+describe("representative plugin decision metadata", () => {
+ it.each(["blog", "form-builder", "open-api"] as const)(
+ "publishes the shared evaluator contract for %s",
+ (key) => {
+ const { decision } = representativePlugin(key);
+
+ expect(decision.releaseStatus).toBe("Preview");
+ expect(decision.supportedFrameworks).toEqual(maintainedFrameworks);
+ expect(decision.docsPath).toBe(`/plugins/${key}`);
+ expect(decision.sourcePath).toContain(`/plugins/${key}`);
+ expect(decision.supplies.length).toBeGreaterThan(0);
+ expect(decision.adopterSupplies.length).toBeGreaterThan(0);
+ expect(decision).not.toHaveProperty("audience");
+ expect(decision).not.toHaveProperty("ownership");
+ expect(decision).not.toHaveProperty("workflow");
+ expect(decision).not.toHaveProperty("actions");
+ },
+ );
+
+ it("describes Blog as the complete feature proof with a working live result", () => {
+ const { plugin, decision } = representativePlugin("blog");
+
+ expect(decision.topology).toBe("Full-stack");
+ expect(plugin.backendImportPath).toBeDefined();
+ expect(plugin.clientImportPath).toBeDefined();
+ expect(decision.demoPath).toBe("https://www.better-stack.ai/p/blog");
+ expect(decision.adopterSupplies).toContain(
+ "An authorization policy when protected authoring operations are enabled",
+ );
+ });
+
+ it("states the complete Form Builder data workflow without inventing a demo", () => {
+ const { plugin, decision } = representativePlugin("form-builder");
+
+ expect(decision.topology).toBe("Full-stack");
+ expect(plugin.backendImportPath).toBeDefined();
+ expect(plugin.clientImportPath).toBeDefined();
+ expect(decision.demoPath).toBeUndefined();
+ expect(decision.dependencies).toContain(
+ "A database adapter with isolated transaction support",
+ );
+ expect(decision.adopterSupplies).toContain(
+ "The public application route that mounts FormRenderer",
+ );
+ expect(decision.adopterSupplies).toContain(
+ "An explicit public-access or permission policy for form reads and submissions when authorization is enabled",
+ );
+ });
+
+ it("keeps OpenAPI backend-only and Scalar optional", () => {
+ const { plugin, decision } = representativePlugin("open-api");
+
+ expect(decision.topology).toBe("Backend-only");
+ expect(plugin.backendImportPath).toBeDefined();
+ expect(plugin.clientImportPath).toBeUndefined();
+ expect(decision.demoPath).toBeUndefined();
+ expect(decision.supplies).toContain(
+ "A JSON schema endpoint at the configured API base path plus the fixed /open-api/schema suffix",
+ );
+ expect(decision.adopterSupplies).toContain(
+ "Optional overrides for the API title, version, and reference path; defaults are BTST API, 1.0.0, and /reference",
+ );
+ expect(decision.externalServices).toEqual([
+ "The optional Scalar reference loads @scalar/api-reference from jsDelivr",
+ ]);
+ });
+});
diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts
index 30b6b9301..69e5f113b 100644
--- a/packages/cli/src/utils/constants.ts
+++ b/packages/cli/src/utils/constants.ts
@@ -1,4 +1,5 @@
import type { Adapter, PluginKey } from "../types";
+import { PLUGIN_DECISIONS, type PluginDecisionMeta } from "./plugin-decision";
export interface AdapterMeta {
key: Adapter;
@@ -27,6 +28,8 @@ export interface PluginMeta {
extraInstallSpecs?: string[];
/** Whether this plugin has sample seed data available for the playground. */
hasSeedData?: boolean;
+ /** Optional evaluator facts for released plugins with a decision surface. */
+ decision?: PluginDecisionMeta;
}
export const ADAPTERS: readonly AdapterMeta[] = [
@@ -79,6 +82,7 @@ export const PLUGINS: readonly PluginMeta[] = [
clientSymbol: "blogClientPlugin",
configKey: "blog",
hasSeedData: true,
+ decision: PLUGIN_DECISIONS.blog,
},
{
key: "ai-chat",
@@ -112,6 +116,7 @@ export const PLUGINS: readonly PluginMeta[] = [
clientSymbol: "formBuilderClientPlugin",
configKey: "formBuilder",
hasSeedData: true,
+ decision: PLUGIN_DECISIONS["form-builder"],
},
{
key: "ui-builder",
@@ -172,6 +177,7 @@ export const PLUGINS: readonly PluginMeta[] = [
backendImportPath: "@btst/stack/plugins/open-api/api",
backendSymbol: "openApiBackendPlugin",
configKey: "openApi",
+ decision: PLUGIN_DECISIONS["open-api"],
},
{
key: "better-auth-ui",
diff --git a/packages/cli/src/utils/plugin-decision.ts b/packages/cli/src/utils/plugin-decision.ts
new file mode 100644
index 000000000..a7c5fed5a
--- /dev/null
+++ b/packages/cli/src/utils/plugin-decision.ts
@@ -0,0 +1,129 @@
+import type { PluginKey } from "../types";
+
+/** Registration boundary of an installable BTST plugin. */
+type PluginTopology = "Full-stack" | "Backend-only" | "Client-only";
+
+/** Optional relationship to another BTST plugin or external system. */
+type PluginRelationship = "Companion" | "Dependent";
+
+/** Evidence-based release status shown on evaluator surfaces. */
+type PluginReleaseStatus = "Stable" | "Preview";
+
+/** Framework integrations covered by the maintained v3 integration paths. */
+type MaintainedFramework =
+ | "Next.js 15+ App Router"
+ | "React Router v7"
+ | "TanStack Start";
+
+/**
+ * Versioned technical facts used to evaluate an installable BTST plugin.
+ * Audience copy, workflow wording, calls to action, and visual presentation are
+ * owned by the surface that renders these facts.
+ */
+export interface PluginDecisionMeta {
+ /** Boundary relative to the registered BTST backend and client stacks. */
+ topology: PluginTopology;
+ /** Relationship to another plugin or external system, when one exists. */
+ relationship?: PluginRelationship;
+ /** Evidence-based support status for the released plugin. */
+ releaseStatus: PluginReleaseStatus;
+ /** Concrete runtime, data, route, or UI capabilities shipped by BTST. */
+ supplies: readonly string[];
+ /** Application-specific infrastructure or policy the adopter provides. */
+ adopterSupplies: readonly string[];
+ /** Required BTST, database, or runtime dependencies. */
+ dependencies: readonly string[];
+ /** External services contacted by the plugin, including optional services. */
+ externalServices: readonly string[];
+ /** Maintained framework integration paths for this plugin. */
+ supportedFrameworks: readonly MaintainedFramework[];
+ /** Canonical documentation identifier. */
+ docsPath: `/plugins/${string}`;
+ /** Canonical working demo identifier, when one exists. */
+ demoPath?: `https://${string}`;
+ /** Canonical public source identifier. */
+ sourcePath: `https://github.com/better-stack-ai/better-stack/${string}`;
+}
+
+type RepresentativePluginKey = Extract<
+ PluginKey,
+ "blog" | "form-builder" | "open-api"
+>;
+
+const MAINTAINED_FRAMEWORKS = [
+ "Next.js 15+ App Router",
+ "React Router v7",
+ "TanStack Start",
+] as const satisfies readonly MaintainedFramework[];
+
+export const PLUGIN_DECISIONS = {
+ blog: {
+ topology: "Full-stack",
+ releaseStatus: "Preview",
+ supplies: [
+ "Post and tag data models with typed CRUD APIs and lifecycle hooks",
+ "SSR-aware list, draft, editor, tag, and post routes",
+ "Published-page metadata and sitemap entries",
+ "Customizable Blog pages, hooks, and editor UI",
+ ],
+ adopterSupplies: [
+ "A BTST database adapter",
+ "An image upload implementation when editor uploads are enabled",
+ "An authorization policy when protected authoring operations are enabled",
+ "The application shell, public origin, and deployment",
+ ],
+ dependencies: ["A BTST database adapter"],
+ externalServices: [],
+ supportedFrameworks: MAINTAINED_FRAMEWORKS,
+ docsPath: "/plugins/blog",
+ demoPath: "https://www.better-stack.ai/p/blog",
+ sourcePath:
+ "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/blog",
+ },
+ "form-builder": {
+ topology: "Full-stack",
+ releaseStatus: "Preview",
+ supplies: [
+ "Drag-and-drop editor with live preview and JSON Schema output",
+ "Form and submission data models with typed APIs and lifecycle hooks",
+ "Admin routes for forms, editing, and submission review",
+ "A FormRenderer component for adopter-owned public routes",
+ ],
+ adopterSupplies: [
+ "A database adapter with isolated transaction support",
+ "The public application route that mounts FormRenderer",
+ "Authorization rules for admin operations when authorization is enabled",
+ "An explicit public-access or permission policy for form reads and submissions when authorization is enabled",
+ ],
+ dependencies: ["A database adapter with isolated transaction support"],
+ externalServices: [],
+ supportedFrameworks: MAINTAINED_FRAMEWORKS,
+ docsPath: "/plugins/form-builder",
+ sourcePath:
+ "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/form-builder",
+ },
+ "open-api": {
+ topology: "Backend-only",
+ releaseStatus: "Preview",
+ supplies: [
+ "Deterministic OpenAPI 3.1 generation from registered route metadata and Zod inputs",
+ "A JSON schema endpoint at the configured API base path plus the fixed /open-api/schema suffix",
+ "An optional Scalar HTML reference endpoint with theme and CSP nonce options",
+ "Public or permission-ID access metadata for documented operations",
+ ],
+ adopterSupplies: [
+ "A registered BTST backend stack whose routes can be inspected",
+ "A configured API base path; the schema suffix remains /open-api/schema",
+ "Optional overrides for the API title, version, and reference path; defaults are BTST API, 1.0.0, and /reference",
+ "A framework-level access policy if the documentation must be private",
+ ],
+ dependencies: ["A registered BTST backend stack to inspect"],
+ externalServices: [
+ "The optional Scalar reference loads @scalar/api-reference from jsDelivr",
+ ],
+ supportedFrameworks: MAINTAINED_FRAMEWORKS,
+ docsPath: "/plugins/open-api",
+ sourcePath:
+ "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/open-api",
+ },
+} as const satisfies Record;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f116f7ef4..6e279d6e5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -207,6 +207,9 @@ importers:
docs:
dependencies:
+ '@btst/codegen':
+ specifier: workspace:*
+ version: link:../packages/cli
'@btst/stack':
specifier: workspace:*
version: link:../packages/stack
From a521368cee130e074dcdaa745e0e235994c876f6 Mon Sep 17 00:00:00 2001
From: olliethedev <5933733+olliethedev@users.noreply.github.com>
Date: Tue, 1 Sep 2026 16:56:48 -0400
Subject: [PATCH 2/3] build: publish plugin decision manifest (#262)
---
docs/content/docs/plugins/index.mdx | 15 ++++
packages/cli/package.json | 8 +-
packages/cli/plugin-decisions.json | 83 +++++++++++++++++++
.../cli/scripts/generate-plugin-decisions.ts | 53 ++++++++++++
.../plugin-decision-manifest.test.ts | 45 ++++++++++
5 files changed, 202 insertions(+), 2 deletions(-)
create mode 100644 packages/cli/plugin-decisions.json
create mode 100644 packages/cli/scripts/generate-plugin-decisions.ts
create mode 100644 packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts
diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx
index ead7c91b9..4f08779e8 100644
--- a/docs/content/docs/plugins/index.mdx
+++ b/docs/content/docs/plugins/index.mdx
@@ -96,3 +96,18 @@ docs, demo, and source identifiers. Audience copy, workflow wording, and calls
to action remain presentation-owned.
+
+Consumers that cannot wait for a package release can read the checked-in
+[`plugin-decisions.json`](https://raw.githubusercontent.com/better-stack-ai/better-stack/main/packages/cli/plugin-decisions.json)
+during their build or content-validation step. Pin the raw URL to a commit when
+reproducibility matters. The same artifact is published as
+`@btst/codegen/plugin-decisions.json`; check `schemaVersion` before consuming its
+three `plugins` records. Do not fetch it from the browser at runtime.
+
+After changing `PLUGIN_DECISIONS`, regenerate and verify the artifact from the
+repository root:
+
+```bash
+pnpm --filter @btst/codegen plugin-decisions:generate
+pnpm --filter @btst/codegen plugin-decisions:check
+```
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a41817cf6..db706a5c2 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -54,12 +54,14 @@
"types": "./dist/meta.d.cts",
"default": "./dist/meta.cjs"
}
- }
+ },
+ "./plugin-decisions.json": "./plugin-decisions.json"
},
"files": [
"dist",
"src",
- "scripts"
+ "scripts",
+ "plugin-decisions.json"
],
"scripts": {
"build": "unbuild --clean",
@@ -67,6 +69,8 @@
"dev": "tsx src/index.ts",
"legacy-next-hashes:check": "node scripts/generate-legacy-next-render-hashes.mjs --check",
"legacy-next-hashes:generate": "node scripts/generate-legacy-next-render-hashes.mjs",
+ "plugin-decisions:check": "tsx scripts/generate-plugin-decisions.ts --check",
+ "plugin-decisions:generate": "tsx scripts/generate-plugin-decisions.ts --write",
"typecheck": "tsc --project tsconfig.json",
"test": "vitest run",
"test:init": "bash scripts/test-init.sh",
diff --git a/packages/cli/plugin-decisions.json b/packages/cli/plugin-decisions.json
new file mode 100644
index 000000000..c6c7c2ac6
--- /dev/null
+++ b/packages/cli/plugin-decisions.json
@@ -0,0 +1,83 @@
+{
+ "schemaVersion": 1,
+ "plugins": {
+ "blog": {
+ "topology": "Full-stack",
+ "releaseStatus": "Preview",
+ "supplies": [
+ "Post and tag data models with typed CRUD APIs and lifecycle hooks",
+ "SSR-aware list, draft, editor, tag, and post routes",
+ "Published-page metadata and sitemap entries",
+ "Customizable Blog pages, hooks, and editor UI"
+ ],
+ "adopterSupplies": [
+ "A BTST database adapter",
+ "An image upload implementation when editor uploads are enabled",
+ "An authorization policy when protected authoring operations are enabled",
+ "The application shell, public origin, and deployment"
+ ],
+ "dependencies": ["A BTST database adapter"],
+ "externalServices": [],
+ "supportedFrameworks": [
+ "Next.js 15+ App Router",
+ "React Router v7",
+ "TanStack Start"
+ ],
+ "docsPath": "/plugins/blog",
+ "demoPath": "https://www.better-stack.ai/p/blog",
+ "sourcePath": "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/blog"
+ },
+ "form-builder": {
+ "topology": "Full-stack",
+ "releaseStatus": "Preview",
+ "supplies": [
+ "Drag-and-drop editor with live preview and JSON Schema output",
+ "Form and submission data models with typed APIs and lifecycle hooks",
+ "Admin routes for forms, editing, and submission review",
+ "A FormRenderer component for adopter-owned public routes"
+ ],
+ "adopterSupplies": [
+ "A database adapter with isolated transaction support",
+ "The public application route that mounts FormRenderer",
+ "Authorization rules for admin operations when authorization is enabled",
+ "An explicit public-access or permission policy for form reads and submissions when authorization is enabled"
+ ],
+ "dependencies": ["A database adapter with isolated transaction support"],
+ "externalServices": [],
+ "supportedFrameworks": [
+ "Next.js 15+ App Router",
+ "React Router v7",
+ "TanStack Start"
+ ],
+ "docsPath": "/plugins/form-builder",
+ "sourcePath": "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/form-builder"
+ },
+ "open-api": {
+ "topology": "Backend-only",
+ "releaseStatus": "Preview",
+ "supplies": [
+ "Deterministic OpenAPI 3.1 generation from registered route metadata and Zod inputs",
+ "A JSON schema endpoint at the configured API base path plus the fixed /open-api/schema suffix",
+ "An optional Scalar HTML reference endpoint with theme and CSP nonce options",
+ "Public or permission-ID access metadata for documented operations"
+ ],
+ "adopterSupplies": [
+ "A registered BTST backend stack whose routes can be inspected",
+ "A configured API base path; the schema suffix remains /open-api/schema",
+ "Optional overrides for the API title, version, and reference path; defaults are BTST API, 1.0.0, and /reference",
+ "A framework-level access policy if the documentation must be private"
+ ],
+ "dependencies": ["A registered BTST backend stack to inspect"],
+ "externalServices": [
+ "The optional Scalar reference loads @scalar/api-reference from jsDelivr"
+ ],
+ "supportedFrameworks": [
+ "Next.js 15+ App Router",
+ "React Router v7",
+ "TanStack Start"
+ ],
+ "docsPath": "/plugins/open-api",
+ "sourcePath": "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/open-api"
+ }
+ }
+}
diff --git a/packages/cli/scripts/generate-plugin-decisions.ts b/packages/cli/scripts/generate-plugin-decisions.ts
new file mode 100644
index 000000000..715f1cc0c
--- /dev/null
+++ b/packages/cli/scripts/generate-plugin-decisions.ts
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+
+import { execFileSync } from "node:child_process";
+import { readFile, writeFile } from "node:fs/promises";
+import { createRequire } from "node:module";
+import { resolve } from "node:path";
+import { PLUGIN_DECISIONS } from "../src/utils/plugin-decision";
+
+const manifestPath = resolve(import.meta.dirname, "../plugin-decisions.json");
+const biomeCli = createRequire(import.meta.url).resolve(
+ "@biomejs/biome/bin/biome",
+);
+const output = execFileSync(
+ biomeCli,
+ ["format", "--stdin-file-path", manifestPath],
+ {
+ encoding: "utf8",
+ input: `${JSON.stringify(
+ { schemaVersion: 1, plugins: PLUGIN_DECISIONS },
+ null,
+ 2,
+ )}\n`,
+ },
+);
+
+const shouldCheck = process.argv.includes("--check");
+const shouldWrite = process.argv.includes("--write");
+
+if (shouldCheck === shouldWrite) {
+ throw new Error("Pass exactly one of --check or --write.");
+}
+
+if (shouldCheck) {
+ let current: string;
+ try {
+ current = await readFile(manifestPath, "utf8");
+ } catch {
+ console.error(
+ "Plugin decision manifest is missing. Run `pnpm plugin-decisions:generate`.",
+ );
+ process.exitCode = 1;
+ current = "";
+ }
+
+ if (current && current !== output) {
+ console.error(
+ "Plugin decision manifest drifted. Run `pnpm plugin-decisions:generate` and review the JSON change.",
+ );
+ process.exitCode = 1;
+ }
+} else {
+ await writeFile(manifestPath, output, "utf8");
+}
diff --git a/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts b/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts
new file mode 100644
index 000000000..c8106b429
--- /dev/null
+++ b/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts
@@ -0,0 +1,45 @@
+import { execFile } from "node:child_process";
+import { readFile } from "node:fs/promises";
+import { createRequire } from "node:module";
+import { resolve } from "node:path";
+import { promisify } from "node:util";
+import { describe, expect, it } from "vitest";
+import { PLUGIN_DECISIONS } from "../plugin-decision";
+
+const packageRoot = resolve(import.meta.dirname, "../../..");
+const manifestPath = resolve(packageRoot, "plugin-decisions.json");
+const packageJsonPath = resolve(packageRoot, "package.json");
+const generatorPath = resolve(
+ packageRoot,
+ "scripts/generate-plugin-decisions.ts",
+);
+const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
+const execFileAsync = promisify(execFile);
+
+describe("published plugin decision manifest", () => {
+ it("matches the versioned core decision inventory byte for byte", async () => {
+ const contents = await readFile(manifestPath, "utf8");
+ const manifest = JSON.parse(contents);
+
+ expect(manifest).toEqual({ schemaVersion: 1, plugins: PLUGIN_DECISIONS });
+ expect(Object.keys(manifest.plugins)).toEqual([
+ "blog",
+ "form-builder",
+ "open-api",
+ ]);
+ await expect(
+ execFileAsync(process.execPath, [tsxCli, generatorPath, "--check"]),
+ ).resolves.toMatchObject({ stderr: "" });
+ });
+
+ it("ships the JSON artifact through the public package contract", async () => {
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
+
+ expect(packageJson.files).toContain("plugin-decisions.json");
+ expect(packageJson.exports["./plugin-decisions.json"]).toBe(
+ "./plugin-decisions.json",
+ );
+ expect(packageJson.scripts["plugin-decisions:generate"]).toBeDefined();
+ expect(packageJson.scripts["plugin-decisions:check"]).toBeDefined();
+ });
+});
From 7fa4b68afc15d633f1553c96585e36e070c1f7b6 Mon Sep 17 00:00:00 2001
From: olliethedev <5933733+olliethedev@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:09:18 -0400
Subject: [PATCH 3/3] test: reject empty plugin decision manifests
---
.../cli/scripts/generate-plugin-decisions.ts | 9 ++--
.../plugin-decision-manifest.test.ts | 46 ++++++++++++++++++-
2 files changed, 49 insertions(+), 6 deletions(-)
diff --git a/packages/cli/scripts/generate-plugin-decisions.ts b/packages/cli/scripts/generate-plugin-decisions.ts
index 715f1cc0c..64c31db6b 100644
--- a/packages/cli/scripts/generate-plugin-decisions.ts
+++ b/packages/cli/scripts/generate-plugin-decisions.ts
@@ -6,7 +6,9 @@ import { createRequire } from "node:module";
import { resolve } from "node:path";
import { PLUGIN_DECISIONS } from "../src/utils/plugin-decision";
-const manifestPath = resolve(import.meta.dirname, "../plugin-decisions.json");
+const manifestPath =
+ process.env.BTST_PLUGIN_DECISIONS_MANIFEST_PATH ??
+ resolve(import.meta.dirname, "../plugin-decisions.json");
const biomeCli = createRequire(import.meta.url).resolve(
"@biomejs/biome/bin/biome",
);
@@ -31,7 +33,7 @@ if (shouldCheck === shouldWrite) {
}
if (shouldCheck) {
- let current: string;
+ let current: string | undefined;
try {
current = await readFile(manifestPath, "utf8");
} catch {
@@ -39,10 +41,9 @@ if (shouldCheck) {
"Plugin decision manifest is missing. Run `pnpm plugin-decisions:generate`.",
);
process.exitCode = 1;
- current = "";
}
- if (current && current !== output) {
+ if (current !== undefined && current !== output) {
console.error(
"Plugin decision manifest drifted. Run `pnpm plugin-decisions:generate` and review the JSON change.",
);
diff --git a/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts b/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts
index c8106b429..b417dfeaa 100644
--- a/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts
+++ b/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts
@@ -1,7 +1,8 @@
import { execFile } from "node:child_process";
-import { readFile } from "node:fs/promises";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
-import { resolve } from "node:path";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { PLUGIN_DECISIONS } from "../plugin-decision";
@@ -42,4 +43,45 @@ describe("published plugin decision manifest", () => {
expect(packageJson.scripts["plugin-decisions:generate"]).toBeDefined();
expect(packageJson.scripts["plugin-decisions:check"]).toBeDefined();
});
+
+ it("rejects an existing empty manifest instead of treating it as missing", async () => {
+ const temporaryRoot = await mkdtemp(join(tmpdir(), "btst-decisions-"));
+ const emptyManifestPath = join(temporaryRoot, "plugin-decisions.json");
+ await writeFile(emptyManifestPath, "", "utf8");
+
+ try {
+ await expect(
+ execFileAsync(process.execPath, [tsxCli, generatorPath, "--check"], {
+ env: {
+ ...process.env,
+ BTST_PLUGIN_DECISIONS_MANIFEST_PATH: emptyManifestPath,
+ },
+ }),
+ ).rejects.toMatchObject({
+ stderr: expect.stringContaining("Plugin decision manifest drifted"),
+ });
+ } finally {
+ await rm(temporaryRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("reports a missing manifest distinctly from an empty one", async () => {
+ const temporaryRoot = await mkdtemp(join(tmpdir(), "btst-decisions-"));
+ const missingManifestPath = join(temporaryRoot, "missing.json");
+
+ try {
+ await expect(
+ execFileAsync(process.execPath, [tsxCli, generatorPath, "--check"], {
+ env: {
+ ...process.env,
+ BTST_PLUGIN_DECISIONS_MANIFEST_PATH: missingManifestPath,
+ },
+ }),
+ ).rejects.toMatchObject({
+ stderr: expect.stringContaining("Plugin decision manifest is missing"),
+ });
+ } finally {
+ await rm(temporaryRoot, { recursive: true, force: true });
+ }
+ });
});