feat(conversion): Google-first login, truthful dashboard, SSR marketplace, template seeding

- login: OAuth/email on top, phone collapsed behind 'Sign in with phone
  instead' (prod providers: google on, email off, sms on — a developer
  should never see a phone field as the front door)
- dashboard: plan card wired to GET /v1/billing/status; calls card shows
  '—' + pointer to per-server metrics (no user-facing usage endpoint
  exists; previous card showed invented '0 of 100,000 / Hobby')
- templates: server-rendered grid (revalidate 300) via fetchPublicTemplates,
  client browser hydrates with initial data; inviting empty state with
  labeled starter ideas instead of 'No templates yet'
- servers/new: removed upgrade nag from first analyze wait
- scripts/seed-templates.mjs: idempotent dry-run-by-default seeder driving
  the real preview->create->live->publish flow for 6 first-party templates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
This commit is contained in:
Marco Sadjadi 2026-07-08 23:00:25 +02:00
parent 17056d0b30
commit 089074d104
7 changed files with 775 additions and 402 deletions

View File

@ -17,12 +17,17 @@ interface ServerRow {
export default function Overview() { export default function Overview() {
const [servers, setServers] = useState<ServerRow[] | null>(null); const [servers, setServers] = useState<ServerRow[] | null>(null);
const [plan, setPlan] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
apiFetch<{ servers: ServerRow[] }>('/v1/servers') apiFetch<{ servers: ServerRow[] }>('/v1/servers')
.then((r) => setServers(r.servers)) .then((r) => setServers(r.servers))
.catch((e) => setErr((e as Error).message)); .catch((e) => setErr((e as Error).message));
// Real plan from billing status — never render a hardcoded tier.
apiFetch<{ plan: string }>('/v1/billing/status')
.then((r) => setPlan(r.plan))
.catch(() => setPlan(null));
}, []); }, []);
if (err?.includes('401')) { if (err?.includes('401')) {
@ -46,8 +51,16 @@ export default function Overview() {
<div className="mt-6 grid gap-3 md:grid-cols-3"> <div className="mt-6 grid gap-3 md:grid-cols-3">
<Card label="Servers" value={total.toString()} sub={`${live} live`} /> <Card label="Servers" value={total.toString()} sub={`${live} live`} />
<Card label="Calls this period" value="0" sub="of 100,000" /> <Card
<Card label="Plan" value="Hobby" sub="Upgrade in Settings" /> label="Calls this period"
value="—"
sub="Per-server metrics live on each server page"
/>
<Card
label="Plan"
value={plan ? plan.charAt(0).toUpperCase() + plan.slice(1) : '—'}
sub="Manage in Settings → Billing"
/>
</div> </div>
<div className="mt-10"> <div className="mt-10">

View File

@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button';
import { apiFetch, apiSseStream, humanizeError } from '@/lib/api'; import { apiFetch, apiSseStream, humanizeError } from '@/lib/api';
import { findSecretInPrompt } from '@bmm/types'; import { findSecretInPrompt } from '@bmm/types';
import { Loader2, RotateCcw, X } from 'lucide-react'; import { Loader2, RotateCcw, X } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
@ -558,14 +557,6 @@ function NewServerPageInner() {
drafting the tool spec. Usually{' '} drafting the tool spec. Usually{' '}
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).estimate}. {(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).estimate}.
</p> </p>
{userPlan === 'hobby' && (
<p className="mt-2 text-[11px] text-[--color-fg-muted]">
<Link href="/pricing" className="text-[--color-accent] hover:underline">
Upgrade to Pro
</Link>{' '}
for ~3× faster analysis with Claude Haiku.
</p>
)}
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]"> <p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
{elapsedSec}s elapsed {elapsedSec}s elapsed
</p> </p>

View File

@ -203,9 +203,11 @@ export default function LoginPage() {
sms: false, sms: false,
email: false, email: false,
}); });
// Default to SMS — email is off by default until an SMTP/Resend provider // Phone sign-in is deliberately collapsed behind a text link whenever any
// is wired. The effect below flips to 'email' if the backend says it's on. // other provider (OAuth or email) is available — a developer evaluating the
const [method, setMethod] = useState<'email' | 'phone'>('phone'); // product should never see a phone-number field as the front door. It only
// renders expanded when SMS is the sole configured provider.
const [phoneOpen, setPhoneOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Email magic-link // Email magic-link
@ -226,9 +228,9 @@ export default function LoginPage() {
) )
.then((p) => { .then((p) => {
setProviders(p); setProviders(p);
// Pick the most-likely method up-front: email if enabled, else SMS. // SMS as the only provider → show the phone form directly (no point
if (p.email) setMethod('email'); // hiding the sole sign-in method behind a toggle).
else if (p.sms) setMethod('phone'); if (p.sms && !p.email && !p.google && !p.github) setPhoneOpen(true);
}) })
.catch(() => undefined); .catch(() => undefined);
const err = new URLSearchParams(window.location.search).get('error'); const err = new URLSearchParams(window.location.search).get('error');
@ -317,7 +319,7 @@ export default function LoginPage() {
</div> </div>
)} )}
{hasOAuth && ( {hasOAuth && (providers.email || providers.sms) && (
<div className="my-5 flex items-center gap-3"> <div className="my-5 flex items-center gap-3">
<span className="h-px flex-1 bg-[--color-border]" /> <span className="h-px flex-1 bg-[--color-border]" />
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]"> <span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
@ -327,35 +329,8 @@ export default function LoginPage() {
</div> </div>
)} )}
{/* Tab toggle only shown when BOTH email and SMS are enabled if just
one is configured, that method's form renders directly without a
useless one-tab toggle. */}
{providers.sms && providers.email && (
<div
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
>
{(['email', 'phone'] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => {
setMethod(m);
setError(null);
}}
className={`h-7 flex-1 rounded text-[12px] font-medium transition-colors ${
method === m
? 'bg-[--color-bg-subtle] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
}`}
>
{m === 'email' ? 'Email' : 'Phone'}
</button>
))}
</div>
)}
<div className={providers.sms || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}> <div className={providers.sms || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
{method === 'email' && providers.email && emailState !== 'sent' && ( {providers.email && !phoneOpen && emailState !== 'sent' && (
<form onSubmit={sendMagicLink} className="space-y-3"> <form onSubmit={sendMagicLink} className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
@ -381,7 +356,7 @@ export default function LoginPage() {
</form> </form>
)} )}
{method === 'email' && providers.email && emailState === 'sent' && ( {providers.email && !phoneOpen && emailState === 'sent' && (
<div className="panel p-4"> <div className="panel p-4">
<p className="text-[13px]"> <p className="text-[13px]">
Magic link sent to <span className="mono">{email}</span>. Magic link sent to <span className="mono">{email}</span>.
@ -392,15 +367,11 @@ export default function LoginPage() {
</div> </div>
)} )}
{method === 'phone' && smsStep === 'phone' && ( {providers.sms && phoneOpen && smsStep === 'phone' && (
<form onSubmit={requestSmsCode} className="space-y-3"> <form onSubmit={requestSmsCode} className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="country">Country</Label> <Label htmlFor="country">Country</Label>
<CountryPicker <CountryPicker countries={COUNTRIES} value={country} onChange={setCountry} />
countries={COUNTRIES}
value={country}
onChange={setCountry}
/>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="phone" hint={dialFor(country)}> <Label htmlFor="phone" hint={dialFor(country)}>
@ -429,7 +400,7 @@ export default function LoginPage() {
</form> </form>
)} )}
{method === 'phone' && smsStep === 'code' && ( {providers.sms && phoneOpen && smsStep === 'code' && (
<form onSubmit={verifySmsCode} className="space-y-3"> <form onSubmit={verifySmsCode} className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="code" hint={`sent to ${sentTo}`}> <Label htmlFor="code" hint={`sent to ${sentTo}`}>
@ -471,6 +442,35 @@ export default function LoginPage() {
)} )}
{error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>} {error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>}
{/* Phone sign-in stays available but demoted: expand link when any
other provider exists, collapse link to get back. */}
{providers.sms && !phoneOpen && (hasOAuth || providers.email) && (
<button
type="button"
onClick={() => {
setPhoneOpen(true);
setError(null);
}}
className="mt-4 w-full text-center text-[12px] text-[--color-fg-muted] underline-offset-2 transition-colors hover:text-[--color-fg] hover:underline"
>
Sign in with phone number instead
</button>
)}
{providers.sms && phoneOpen && (hasOAuth || providers.email) && (
<button
type="button"
onClick={() => {
setPhoneOpen(false);
setSmsStep('phone');
setCode('');
setError(null);
}}
className="mt-4 w-full text-center text-[12px] text-[--color-fg-muted] underline-offset-2 transition-colors hover:text-[--color-fg] hover:underline"
>
Use another sign-in method
</button>
)}
</div> </div>
<div className="mt-8 text-[12px] text-[--color-fg-subtle]"> <div className="mt-8 text-[12px] text-[--color-fg-subtle]">

View File

@ -1,351 +1,12 @@
'use client'; import { fetchPublicTemplates } from '@/lib/templates-server';
import { TemplatesBrowser } from './templates-browser';
import { useEffect, useState } from 'react'; // Server component: fetch the default (all/trending) template list at render
import Link from 'next/link'; // time so the marketplace grid is present in the initial HTML for crawlers.
import { ShieldCheck, GitFork, Activity } from 'lucide-react'; // All interactivity (search, filters, scope) lives in TemplatesBrowser.
import { apiFetch } from '@/lib/api'; export const revalidate = 300;
import { cn } from '@/lib/cn';
import { Logo } from '@/components/logo';
import { Input } from '@/components/input';
import { MobileActionBar } from '@/components/mobile-action-bar';
import { UserMenu } from '@/components/user-menu';
interface Template { export default async function TemplatesPage() {
id: string; const { templates, categories } = await fetchPublicTemplates();
slug: string; return <TemplatesBrowser initialTemplates={templates} initialCategories={categories} />;
title: string;
shortDescription: string;
category: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean;
forkCount: number;
activeDeployments: number;
ownerName: string | null;
ownerOrgName: string | null;
sourceServerId: string | null;
createdAt: string;
}
type Sort = 'trending' | 'top';
type Scope = 'all' | 'mine';
const STATUS_STYLE: Record<Template['status'], string> = {
public: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
hidden: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
takedown: 'border-red-400/40 bg-red-400/10 text-red-300',
draft: 'border-zinc-400/40 bg-zinc-400/10 text-zinc-300',
};
export default function TemplatesMarketplace() {
const [me, setMe] = useState<{ email: string } | null | undefined>(undefined);
const [scope, setScope] = useState<Scope>('all');
const [templates, setTemplates] = useState<Template[] | null>(null);
const [categories, setCategories] = useState<string[]>([]);
const [sort, setSort] = useState<Sort>('trending');
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
// Detect login state once
useEffect(() => {
apiFetch<{ user: { email: string } }>('/v1/auth/me')
.then((r) => setMe({ email: r.user.email }))
.catch(() => setMe(null));
}, []);
// Load templates whenever scope/sort/category changes
useEffect(() => {
setTemplates(null);
if (scope === 'mine') {
apiFetch<{ templates: Template[]; categories: string[] }>('/v1/templates/mine')
.then((r) => {
setTemplates(r.templates);
setCategories(r.categories);
})
.catch(() => setTemplates([]));
} else {
const params = new URLSearchParams({ sort });
if (category) params.set('category', category);
apiFetch<{ templates: Template[]; categories: string[] }>(`/v1/templates?${params}`)
.then((r) => {
setTemplates(r.templates);
setCategories(r.categories);
})
.catch(() => setTemplates([]));
}
}, [scope, sort, category]);
const visible = templates?.filter((t) => {
if (search) {
const q = search.toLowerCase();
if (!t.title.toLowerCase().includes(q) && !t.shortDescription.toLowerCase().includes(q)) {
return false;
}
}
// category filter is server-side for 'all', client-side for 'mine'
if (scope === 'mine' && category && t.category !== category) return false;
return true;
});
const loggedIn = me != null;
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 border-b border-[--color-border] bg-[--color-bg]/85 backdrop-blur-md">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between gap-2 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<Logo />
{/* "/ templates" subtitle is redundant on mobile h1 below
already names the page. Keep on desktop as breadcrumb. */}
<span className="hidden text-[12.5px] text-[--color-fg-subtle] sm:inline">
/ templates
</span>
</div>
<nav className="flex items-center gap-1.5 sm:gap-2">
{loggedIn ? (
<>
{/* Dashboard link + "+ New server" pill hidden on mobile
the UserMenu (avatar) + the MobileActionBar below cover
both navigation paths there. */}
<Link
href="/dashboard"
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
>
Dashboard
</Link>
<Link
href="/servers/new"
className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex"
>
+ New server
</Link>
<UserMenu />
</>
) : (
<>
<Link
href="/"
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
>
Home
</Link>
<Link
href="/login"
className="rounded-md bg-[--color-accent] px-2.5 py-1 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:px-3 sm:py-1.5 sm:text-[12.5px]"
>
Start building
</Link>
</>
)}
</nav>
</div>
</header>
<main
className={cn(
'mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6 sm:py-12',
// Bottom-bar clearance on mobile for logged-in users only — the
// MobileActionBar is fixed bottom and would overlap the last row
// of template cards otherwise.
loggedIn && 'pb-24 sm:pb-12',
)}
>
<header className="mb-6 max-w-2xl sm:mb-8">
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
Marketplace
</div>
<h1 className="mt-2 text-[24px] font-semibold tracking-tight sm:text-[32px]">
MCP server templates
</h1>
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted] sm:mt-3 sm:text-[14px]">
Pre-built MCP servers from the community. Fork in one click your own container, your
own credentials, fully isolated. The template author never sees your data.
</p>
</header>
{/* Filter row: stacks vertically on mobile (search on top for thumb
reach), inline on desktop. The order utility on the Input flips
it to the right side at sm: while filters wrap normally. */}
<div className="mb-6 flex flex-col gap-3 border-b border-[--color-border] pb-4 sm:flex-row sm:flex-wrap sm:items-center">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="order-first w-full sm:order-last sm:ml-auto sm:w-60"
/>
{/* Chips row horizontally scrollable on narrow mobile so the
segmented controls never get squeezed below their min-width. */}
<div className="-mx-1 flex items-center gap-2 overflow-x-auto px-1 sm:m-0 sm:flex-wrap sm:gap-3 sm:overflow-visible sm:p-0">
{loggedIn && (
<>
<div className="flex shrink-0 gap-1 rounded-md border border-[--color-border] bg-[--color-bg-subtle] p-0.5">
{(['all', 'mine'] as Scope[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
'rounded-[4px] px-2.5 py-1 text-[12.5px] capitalize transition-colors',
s === scope
? 'bg-[--color-bg-elevated] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{s === 'all' ? 'All' : 'Mine'}
</button>
))}
</div>
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
</>
)}
{scope === 'all' && (
<>
<div className="flex shrink-0 gap-1">
{(['trending', 'top'] as Sort[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setSort(s)}
className={cn(
'rounded-md px-2.5 py-1 text-[12.5px] capitalize transition-colors',
s === sort
? 'bg-[--color-bg-elevated] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{s}
</button>
))}
</div>
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
</>
)}
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
// Hard width-cap — without it a long category name in the
// selected slot would blow the chip row out on mobile and
// push the scrollable region beyond the viewport. Native
// <select> truncates the visible label with ellipsis when
// the box is narrower than the text; the dropdown panel
// itself still shows full names when opened.
className="h-7 w-[140px] shrink-0 truncate rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[12.5px] focus:border-[--color-accent] focus:outline-none sm:w-[160px]"
>
<option value="">All categories</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
{!visible && <p className="mono text-[12px] text-[--color-fg-muted]">Loading</p>}
{visible && visible.length === 0 && (
<div className="panel p-12 text-center">
{scope === 'mine' ? (
<>
<p className="text-[14px] text-[--color-fg]">You haven&apos;t published any templates.</p>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Build a server, then tick <span className="mono">Share as template</span> on the
done screen or use the Publish tab on any live server.
</p>
</>
) : (
<>
<p className="text-[14px] text-[--color-fg]">No templates yet.</p>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Build a server you&apos;re proud of and share it to the marketplace.
</p>
</>
)}
</div>
)}
{visible && visible.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{visible.map((t) => (
<TemplateCard key={t.id} t={t} showStatus={scope === 'mine'} />
))}
</div>
)}
</main>
<footer className="border-t border-[--color-border] py-8">
<div className="mx-auto max-w-6xl px-4 text-[12px] text-[--color-fg-subtle] sm:px-6">
Every template is isolated: forking creates your own container with your own secrets.
</div>
</footer>
{/* Mobile tab-bar only when signed in. Logged-out marketplace
browsing keeps the simple marketing chrome (login CTA in header). */}
{loggedIn && <MobileActionBar />}
</div>
);
}
function TemplateCard({ t, showStatus }: { t: Template; showStatus: boolean }) {
// takedown templates can't be opened (the detail route 410s); link to the
// server's Publish tab so the owner can see why. Everything else opens detail.
const href =
t.status === 'takedown' && t.sourceServerId
? `/servers/${t.sourceServerId}`
: `/templates/${t.slug}`;
return (
<Link
href={href}
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5">
<h2 className="text-[14.5px] font-semibold tracking-tight">{t.title}</h2>
{t.verified && (
<span
className="inline-flex items-center gap-0.5 rounded-full border border-[--color-accent]/40 bg-[--color-accent]/10 px-1.5 py-0.5 text-[9.5px] font-medium text-[--color-accent]"
title="Verified by BuildMyMCPServer"
>
<ShieldCheck size={9} /> verified
</span>
)}
</div>
{showStatus ? (
<span
className={cn(
'mono rounded-full border px-1.5 py-0.5 text-[9.5px]',
STATUS_STYLE[t.status],
)}
>
{t.status}
</span>
) : (
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-1.5 py-0.5 text-[10px] text-[--color-fg-subtle]">
{t.category}
</span>
)}
</div>
<p className="mt-2 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
{t.shortDescription}
</p>
<div className="mt-4 flex items-center justify-between text-[11px] text-[--color-fg-subtle]">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1 mono" title="Total forks">
<GitFork size={11} />
{t.forkCount}
</span>
<span className="inline-flex items-center gap-1 mono" title="Active deployments">
<Activity size={11} />
{t.activeDeployments}
</span>
</div>
<span className="truncate">
{showStatus ? t.category : `by ${t.ownerName ?? t.ownerOrgName ?? 'anonymous'}`}
</span>
</div>
</Link>
);
} }

View File

@ -0,0 +1,395 @@
'use client';
import { Input } from '@/components/input';
import { Logo } from '@/components/logo';
import { MobileActionBar } from '@/components/mobile-action-bar';
import { UserMenu } from '@/components/user-menu';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { TemplateSummary } from '@/lib/templates-server';
import { Activity, GitFork, Lightbulb, ShieldCheck } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
type Template = TemplateSummary;
type Sort = 'trending' | 'top';
type Scope = 'all' | 'mine';
const STATUS_STYLE: Record<Template['status'], string> = {
public: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
hidden: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
takedown: 'border-red-400/40 bg-red-400/10 text-red-300',
draft: 'border-zinc-400/40 bg-zinc-400/10 text-zinc-300',
};
// Honest inspiration cards for the young-marketplace empty state — clearly
// labeled ideas, not fake templates with invented usage numbers.
const STARTER_IDEAS: { title: string; desc: string }[] = [
{
title: 'Notion workspace search',
desc: 'search_pages + get_page_content over your Notion API key. One prompt, ~60s to live.',
},
{
title: 'Read-only PostgreSQL',
desc: 'Let Claude query your tables with schema introspection — no write access.',
},
{
title: 'Wrap any REST API',
desc: 'Turn the endpoints you already have into typed MCP tools behind OAuth.',
},
];
export function TemplatesBrowser({
initialTemplates,
initialCategories,
}: {
initialTemplates: Template[];
initialCategories: string[];
}) {
const [me, setMe] = useState<{ email: string } | null | undefined>(undefined);
const [scope, setScope] = useState<Scope>('all');
const [templates, setTemplates] = useState<Template[] | null>(initialTemplates);
const [categories, setCategories] = useState<string[]>(initialCategories);
const [sort, setSort] = useState<Sort>('trending');
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
// Detect login state once
useEffect(() => {
apiFetch<{ user: { email: string } }>('/v1/auth/me')
.then((r) => setMe({ email: r.user.email }))
.catch(() => setMe(null));
}, []);
// Load templates whenever scope/sort/category changes. The first run is
// skipped: the server component already fetched the default (all/trending)
// list and passed it as props, so the grid is in the SSR HTML for crawlers.
const skippedInitialLoad = useRef(false);
useEffect(() => {
if (!skippedInitialLoad.current) {
skippedInitialLoad.current = true;
return;
}
setTemplates(null);
if (scope === 'mine') {
apiFetch<{ templates: Template[]; categories: string[] }>('/v1/templates/mine')
.then((r) => {
setTemplates(r.templates);
setCategories(r.categories);
})
.catch(() => setTemplates([]));
} else {
const params = new URLSearchParams({ sort });
if (category) params.set('category', category);
apiFetch<{ templates: Template[]; categories: string[] }>(`/v1/templates?${params}`)
.then((r) => {
setTemplates(r.templates);
setCategories(r.categories);
})
.catch(() => setTemplates([]));
}
}, [scope, sort, category]);
const visible = templates?.filter((t) => {
if (search) {
const q = search.toLowerCase();
if (!t.title.toLowerCase().includes(q) && !t.shortDescription.toLowerCase().includes(q)) {
return false;
}
}
// category filter is server-side for 'all', client-side for 'mine'
if (scope === 'mine' && category && t.category !== category) return false;
return true;
});
const loggedIn = me != null;
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 border-b border-[--color-border] bg-[--color-bg]/85 backdrop-blur-md">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between gap-2 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<Logo />
{/* "/ templates" subtitle is redundant on mobile h1 below
already names the page. Keep on desktop as breadcrumb. */}
<span className="hidden text-[12.5px] text-[--color-fg-subtle] sm:inline">
/ templates
</span>
</div>
<nav className="flex items-center gap-1.5 sm:gap-2">
{loggedIn ? (
<>
{/* Dashboard link + "+ New server" pill hidden on mobile
the UserMenu (avatar) + the MobileActionBar below cover
both navigation paths there. */}
<Link
href="/dashboard"
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
>
Dashboard
</Link>
<Link
href="/servers/new"
className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex"
>
+ New server
</Link>
<UserMenu />
</>
) : (
<>
<Link
href="/"
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
>
Home
</Link>
<Link
href="/login"
className="rounded-md bg-[--color-accent] px-2.5 py-1 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:px-3 sm:py-1.5 sm:text-[12.5px]"
>
Start building
</Link>
</>
)}
</nav>
</div>
</header>
<main
className={cn(
'mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6 sm:py-12',
// Bottom-bar clearance on mobile for logged-in users only — the
// MobileActionBar is fixed bottom and would overlap the last row
// of template cards otherwise.
loggedIn && 'pb-24 sm:pb-12',
)}
>
<header className="mb-6 max-w-2xl sm:mb-8">
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
Marketplace
</div>
<h1 className="mt-2 text-[24px] font-semibold tracking-tight sm:text-[32px]">
MCP server templates
</h1>
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted] sm:mt-3 sm:text-[14px]">
Pre-built MCP servers from the community. Fork in one click your own container, your
own credentials, fully isolated. The template author never sees your data.
</p>
</header>
{/* Filter row: stacks vertically on mobile (search on top for thumb
reach), inline on desktop. The order utility on the Input flips
it to the right side at sm: while filters wrap normally. */}
<div className="mb-6 flex flex-col gap-3 border-b border-[--color-border] pb-4 sm:flex-row sm:flex-wrap sm:items-center">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="order-first w-full sm:order-last sm:ml-auto sm:w-60"
/>
{/* Chips row horizontally scrollable on narrow mobile so the
segmented controls never get squeezed below their min-width. */}
<div className="-mx-1 flex items-center gap-2 overflow-x-auto px-1 sm:m-0 sm:flex-wrap sm:gap-3 sm:overflow-visible sm:p-0">
{loggedIn && (
<>
<div className="flex shrink-0 gap-1 rounded-md border border-[--color-border] bg-[--color-bg-subtle] p-0.5">
{(['all', 'mine'] as Scope[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
'rounded-[4px] px-2.5 py-1 text-[12.5px] capitalize transition-colors',
s === scope
? 'bg-[--color-bg-elevated] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{s === 'all' ? 'All' : 'Mine'}
</button>
))}
</div>
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
</>
)}
{scope === 'all' && (
<>
<div className="flex shrink-0 gap-1">
{(['trending', 'top'] as Sort[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setSort(s)}
className={cn(
'rounded-md px-2.5 py-1 text-[12.5px] capitalize transition-colors',
s === sort
? 'bg-[--color-bg-elevated] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{s}
</button>
))}
</div>
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
</>
)}
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
// Hard width-cap — without it a long category name in the
// selected slot would blow the chip row out on mobile and
// push the scrollable region beyond the viewport. Native
// <select> truncates the visible label with ellipsis when
// the box is narrower than the text; the dropdown panel
// itself still shows full names when opened.
className="h-7 w-[140px] shrink-0 truncate rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[12.5px] focus:border-[--color-accent] focus:outline-none sm:w-[160px]"
>
<option value="">All categories</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
{!visible && <p className="mono text-[12px] text-[--color-fg-muted]">Loading</p>}
{visible && visible.length === 0 && scope === 'mine' && (
<div className="panel p-12 text-center">
<p className="text-[14px] text-[--color-fg]">
You haven&apos;t published any templates.
</p>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Build a server, then tick <span className="mono">Share as template</span> on the done
screen or use the Publish tab on any live server.
</p>
</div>
)}
{visible && visible.length === 0 && scope === 'all' && (
<div>
<div className="panel p-8 text-center sm:p-10">
<p className="text-[15px] font-medium text-[--color-fg]">
The marketplace is young be one of the first to publish.
</p>
<p className="mx-auto mt-2 max-w-md text-[12.5px] leading-relaxed text-[--color-fg-muted]">
Every server you build can be shared as a forkable template (your credentials never
travel with it). Not sure where to start? The{' '}
<Link href="/guides" className="text-[--color-accent] hover:underline">
guides
</Link>{' '}
walk through hosting and auth.
</p>
</div>
<p className="mt-8 mb-3 flex items-center gap-1.5 text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
<Lightbulb size={12} /> Starter ideas build one from a single prompt
</p>
<div className="grid gap-3 md:grid-cols-3">
{STARTER_IDEAS.map((idea) => (
<Link
key={idea.title}
href="/login"
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
>
<h2 className="text-[14px] font-semibold tracking-tight">{idea.title}</h2>
<p className="mt-1.5 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
{idea.desc}
</p>
<span className="mt-3 text-[11.5px] text-[--color-accent]">Build this </span>
</Link>
))}
</div>
</div>
)}
{visible && visible.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{visible.map((t) => (
<TemplateCard key={t.id} t={t} showStatus={scope === 'mine'} />
))}
</div>
)}
</main>
<footer className="border-t border-[--color-border] py-8">
<div className="mx-auto max-w-6xl px-4 text-[12px] text-[--color-fg-subtle] sm:px-6">
Every template is isolated: forking creates your own container with your own secrets.
</div>
</footer>
{/* Mobile tab-bar only when signed in. Logged-out marketplace
browsing keeps the simple marketing chrome (login CTA in header). */}
{loggedIn && <MobileActionBar />}
</div>
);
}
function TemplateCard({ t, showStatus }: { t: Template; showStatus: boolean }) {
// takedown templates can't be opened (the detail route 410s); link to the
// server's Publish tab so the owner can see why. Everything else opens detail.
const href =
t.status === 'takedown' && t.sourceServerId
? `/servers/${t.sourceServerId}`
: `/templates/${t.slug}`;
return (
<Link
href={href}
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5">
<h2 className="text-[14.5px] font-semibold tracking-tight">{t.title}</h2>
{t.verified && (
<span
className="inline-flex items-center gap-0.5 rounded-full border border-[--color-accent]/40 bg-[--color-accent]/10 px-1.5 py-0.5 text-[9.5px] font-medium text-[--color-accent]"
title="Verified by BuildMyMCPServer"
>
<ShieldCheck size={9} /> verified
</span>
)}
</div>
{showStatus ? (
<span
className={cn(
'mono rounded-full border px-1.5 py-0.5 text-[9.5px]',
STATUS_STYLE[t.status],
)}
>
{t.status}
</span>
) : (
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-1.5 py-0.5 text-[10px] text-[--color-fg-subtle]">
{t.category}
</span>
)}
</div>
<p className="mt-2 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
{t.shortDescription}
</p>
<div className="mt-4 flex items-center justify-between text-[11px] text-[--color-fg-subtle]">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1 mono" title="Total forks">
<GitFork size={11} />
{t.forkCount}
</span>
<span className="inline-flex items-center gap-1 mono" title="Active deployments">
<Activity size={11} />
{t.activeDeployments}
</span>
</div>
<span className="truncate">
{showStatus ? t.category : `by ${t.ownerName ?? t.ownerOrgName ?? 'anonymous'}`}
</span>
</div>
</Link>
);
}

View File

@ -58,6 +58,44 @@ export async function fetchTemplate(slug: string): Promise<TemplateDetail | null
} }
} }
export interface TemplateSummary {
id: string;
slug: string;
title: string;
shortDescription: string;
category: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean;
forkCount: number;
activeDeployments: number;
ownerName: string | null;
ownerOrgName: string | null;
sourceServerId: string | null;
createdAt: string;
}
/**
* Full public template list for server-rendering the marketplace grid so
* crawlers see the templates in the initial HTML. Best-effort: returns empty
* lists on error so the page still renders (the client component refetches
* on filter changes anyway).
*/
export async function fetchPublicTemplates(): Promise<{
templates: TemplateSummary[];
categories: string[];
}> {
try {
const res = await fetch(`${API_BASE}/v1/templates?sort=trending`, {
next: { revalidate: 300 },
});
if (!res.ok) return { templates: [], categories: [] };
const data = (await res.json()) as { templates?: TemplateSummary[]; categories?: string[] };
return { templates: data.templates ?? [], categories: data.categories ?? [] };
} catch {
return { templates: [], categories: [] };
}
}
/** Slugs of public templates, for the sitemap. Best-effort: returns [] on error. */ /** Slugs of public templates, for the sitemap. Best-effort: returns [] on error. */
export async function fetchPublicTemplateSlugs(): Promise<string[]> { export async function fetchPublicTemplateSlugs(): Promise<string[]> {
try { try {

275
scripts/seed-templates.mjs Normal file
View File

@ -0,0 +1,275 @@
#!/usr/bin/env node
// Seed first-party marketplace templates through the real product flow:
// preview → create server → wait for live → publish as template.
//
// The API only publishes templates from LIVE servers with a successful build,
// so seeding is the honest path — every seeded template carries real generated
// code, exactly what a user's build would produce.
//
// Usage:
// BMM_SESSION=<bmm_session cookie value> node scripts/seed-templates.mjs # dry-run (default)
// BMM_SESSION=... node scripts/seed-templates.mjs --apply # execute
// BMM_API_URL=https://api.buildmymcpserver.com BMM_SESSION=... node ... --apply # against prod (deliberate!)
//
// Secrets: templates whose server needs credentials read them from env
// (SEED_<KEY>). Missing secret → that seed is skipped with a warning, never
// created with a fake value. echo-demo needs none and always works.
//
// Idempotent: seeds whose derived slug already exists in GET /v1/templates
// are skipped. Rate limits apply (previews/builds per day per plan) — run
// under an account with headroom or spread over days.
const API = process.env.BMM_API_URL ?? 'http://localhost:4000';
const SESSION = process.env.BMM_SESSION;
const APPLY = process.argv.includes('--apply');
const BUILD_TIMEOUT_MS = 5 * 60 * 1000;
const POLL_INTERVAL_MS = 5000;
/** @type {Array<{slug: string, name: string, serverSlug: string, category: string, prompt: string, shortDescription: string, longDescription: string, secretEnv: Record<string, string>, secretHints: Array<{key: string, description: string, howToGetUrl?: string}>}>} */
const SEEDS = [
{
slug: 'echo-demo',
name: 'Echo Demo',
serverSlug: 'seed-echo-demo',
category: 'demo',
prompt:
'Create a minimal demo MCP server with two tools: echo (returns the input text unchanged) and now (returns the current UTC timestamp in ISO 8601). No external APIs, no secrets.',
shortDescription:
'The smallest possible MCP server — echo and a UTC clock. Fork it to see the full flow in under a minute.',
longDescription:
'A dependency-free demo server with two tools: `echo` returns whatever text you send it, `now` returns the current UTC timestamp. Useful as a first fork to watch the build pipeline, test your client connection and inspect the OAuth flow before wiring a real API.',
secretEnv: {},
secretHints: [],
},
{
slug: 'notion-search',
name: 'Notion Search',
serverSlug: 'seed-notion-search',
category: 'productivity',
prompt:
'Create an MCP server that searches a Notion workspace. Tools: search_pages (query string, returns matching page titles and ids via the Notion search API) and get_page_content (page_id, returns the page blocks as plain text). Auth: NOTION_API_KEY used as a Bearer token against api.notion.com with Notion-Version header.',
shortDescription:
'Search pages and read content from your Notion workspace. Bring your own integration token.',
longDescription:
'Two tools against the official Notion API: `search_pages` for workspace-wide search and `get_page_content` to read a page as plain text. Fork it, paste your own internal-integration token, and your AI client can look things up in Notion. Read-only.',
secretEnv: { NOTION_API_KEY: 'SEED_NOTION_API_KEY' },
secretHints: [
{
key: 'NOTION_API_KEY',
description:
'Internal integration secret from notion.so/my-integrations (read scope is enough).',
howToGetUrl: 'https://www.notion.so/my-integrations',
},
],
},
{
slug: 'github-issues',
name: 'GitHub Issues',
serverSlug: 'seed-github-issues',
category: 'developer-tools',
prompt:
'Create an MCP server for GitHub issues scoped to a single repository. Tools: list_issues (state filter open/closed/all), get_issue (issue number, returns title/body/labels/comments count) and search_issues (query string). Auth: GITHUB_TOKEN as Bearer token, GITHUB_REPO in owner/repo format used in the API paths.',
shortDescription:
'List, read and search issues in one GitHub repo. Scoped by a fine-grained token you control.',
longDescription:
'Three read-only tools against the GitHub REST API, scoped to a single repository via GITHUB_REPO: `list_issues`, `get_issue` and `search_issues`. Use a fine-grained personal access token with Issues read permission and nothing else.',
secretEnv: { GITHUB_TOKEN: 'SEED_GITHUB_TOKEN', GITHUB_REPO: 'SEED_GITHUB_REPO' },
secretHints: [
{
key: 'GITHUB_TOKEN',
description: 'Fine-grained PAT with read-only Issues permission on the target repo.',
howToGetUrl: 'https://github.com/settings/personal-access-tokens',
},
{ key: 'GITHUB_REPO', description: 'Repository in owner/repo format, e.g. vercel/next.js.' },
],
},
{
slug: 'stripe-readonly',
name: 'Stripe Read-Only',
serverSlug: 'seed-stripe-readonly',
category: 'finance',
prompt:
'Create a read-only MCP server for Stripe. Tools: list_charges (limit param, returns recent charges with amount/currency/status), get_customer (customer id), list_refunds (limit param). Auth: STRIPE_API_KEY as Bearer token against api.stripe.com. Read-only — never create or mutate anything.',
shortDescription:
'Look up charges, customers and refunds from Stripe. Use a restricted read-only key.',
longDescription:
'Read-only Stripe lookups: `list_charges`, `get_customer`, `list_refunds`. Designed for a restricted API key with read-only permissions — the server never writes. Ask your AI “what were yesterdays failed charges?” instead of opening the dashboard.',
secretEnv: { STRIPE_API_KEY: 'SEED_STRIPE_API_KEY' },
secretHints: [
{
key: 'STRIPE_API_KEY',
description: 'Restricted key with read-only scopes (Charges, Customers, Refunds).',
howToGetUrl: 'https://dashboard.stripe.com/apikeys',
},
],
},
{
slug: 'postgres-readonly',
name: 'PostgreSQL Read-Only',
serverSlug: 'seed-postgres-readonly',
category: 'data',
prompt:
'Create a read-only PostgreSQL MCP server. Tools: list_tables (returns schema-qualified table names), describe_table (table name, returns columns and types from information_schema) and run_query (SELECT-only — reject any statement that is not a single SELECT). Auth: DATABASE_URL connection string.',
shortDescription:
'Schema introspection and SELECT-only queries against your Postgres. Nothing gets written.',
longDescription:
'`list_tables`, `describe_table` and a guarded `run_query` that accepts single SELECT statements only. Point it at a read-only database role for defense in depth — the code additionally rejects non-SELECT statements before execution.',
secretEnv: { DATABASE_URL: 'SEED_DATABASE_URL' },
secretHints: [
{
key: 'DATABASE_URL',
description: 'postgres:// connection string — use a read-only role.',
},
],
},
{
slug: 'rest-wrapper',
name: 'REST API Wrapper',
serverSlug: 'seed-rest-wrapper',
category: 'developer-tools',
prompt:
'Create an MCP server that wraps a generic REST API. Tools: get_resource (path param appended to a base URL, returns the JSON response) and search_resources (path plus query-string params object). Auth: API_BASE_URL for the base URL and API_TOKEN sent as a Bearer token. Only allow requests to the configured base URL.',
shortDescription: 'Point it at any JSON REST API — base URL + token in, typed MCP tools out.',
longDescription:
'The template for “I just want my existing API in Claude”: configure API_BASE_URL and API_TOKEN, get `get_resource` and `search_resources` tools that only ever call your configured host. Fork it and iterate the prompt to add endpoint-specific tools.',
secretEnv: { API_BASE_URL: 'SEED_API_BASE_URL', API_TOKEN: 'SEED_API_TOKEN' },
secretHints: [
{
key: 'API_BASE_URL',
description: 'Base URL of your API, e.g. https://api.example.com/v1.',
},
{ key: 'API_TOKEN', description: 'Bearer token the wrapper sends with every request.' },
],
},
];
if (!SESSION) {
console.error(
'BMM_SESSION is required (value of the bmm_session cookie of the seeding account).',
);
process.exit(1);
}
/** @param {string} path @param {RequestInit} [init] */
async function api(path, init = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Cookie: `bmm_session=${SESSION}`,
...(init.headers ?? {}),
},
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = body?.detail ?? body?.error ?? res.status;
throw new Error(`${init.method ?? 'GET'} ${path}${res.status}: ${detail}`);
}
return body;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Derive the slug the publish endpoint will generate from a title. */
function titleSlug(title) {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 48);
}
async function main() {
console.log(`Target API: ${API} ${APPLY ? '(APPLY)' : '(dry-run — pass --apply to execute)'}`);
const existing = await api('/v1/templates?sort=newest');
const existingSlugs = new Set((existing.templates ?? []).map((t) => t.slug));
for (const seed of SEEDS) {
const expectedSlug = titleSlug(seed.name);
if (existingSlugs.has(expectedSlug) || existingSlugs.has(seed.slug)) {
console.log(`${seed.slug} — already published, skipping`);
continue;
}
// Resolve secrets from env; skip seeds we can't provision honestly.
const secrets = {};
let missing = null;
for (const [key, envName] of Object.entries(seed.secretEnv)) {
const val = process.env[envName];
if (!val) {
missing = envName;
break;
}
secrets[key] = val;
}
if (missing) {
console.warn(
`${seed.slug} — skipped: env ${missing} not set (refusing to seed with fake credentials)`,
);
continue;
}
if (!APPLY) {
console.log(
`${seed.slug} — would run preview → create (${seed.serverSlug}) → wait live → publish`,
);
continue;
}
console.log(`${seed.slug} — previewing spec…`);
const preview = await api('/v1/servers/preview', {
method: 'POST',
body: JSON.stringify({ prompt: seed.prompt }),
});
console.log(
` spec ok (${preview.spec.tools.length} tools, source=${preview.source}) — creating server…`,
);
const created = await api('/v1/servers', {
method: 'POST',
body: JSON.stringify({
name: seed.name,
slug: seed.serverSlug,
prompt: seed.prompt,
secrets,
previewId: preview.previewId,
}),
});
const serverId = created.server.id;
process.stdout.write(' building');
const deadline = Date.now() + BUILD_TIMEOUT_MS;
let status = created.server.status;
while (status !== 'live') {
if (Date.now() > deadline)
throw new Error(`${seed.slug}: build timed out (status=${status})`);
if (status === 'failed' || status === 'error') throw new Error(`${seed.slug}: build failed`);
await sleep(POLL_INTERVAL_MS);
const s = await api(`/v1/servers/${serverId}`);
status = s.server.status;
process.stdout.write('.');
}
console.log(' live');
const published = await api('/v1/templates', {
method: 'POST',
body: JSON.stringify({
serverId,
title: seed.name,
shortDescription: seed.shortDescription,
longDescription: seed.longDescription,
category: seed.category,
secretHints: seed.secretHints,
}),
});
console.log(`${seed.slug} — published as /templates/${published.template.slug}`);
}
console.log('Done.');
}
main().catch((err) => {
console.error(`\nSeed failed: ${err.message}`);
process.exit(1);
});