From 8169a7e448b294261de78674561cbd1eec6c613d Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Tue, 12 May 2026 16:57:11 +0200 Subject: [PATCH 1/4] Redesign homepage with ranked plugin leaderboard Replaces the Popular / Recently Added / Most Starred grids and the homepage members section with a single ranked, skills.sh-style leaderboard. - New PluginLeaderboard with All Time / Recent / Most Starred tabs, flat borderless rows, and infinite scroll up to the top 500 - Hero search now filters the leaderboard directly via Fuse instead of rendering separate plugin and member result blocks; placeholder scoped to plugins - Hero copy aligned with Cursor's marketplace voice: "Extend Cursor with community plugins" / "Discover and install plugins from N+ developers, ranked by installs" Co-authored-by: Cursor --- apps/cursor/src/app/page.tsx | 71 +--- .../src/components/global-search-input.tsx | 2 +- apps/cursor/src/components/hero-title.tsx | 11 +- .../components/plugins/plugin-leaderboard.tsx | 353 ++++++++++++++++++ apps/cursor/src/components/startpage.tsx | 221 ++--------- 5 files changed, 402 insertions(+), 256 deletions(-) create mode 100644 apps/cursor/src/components/plugins/plugin-leaderboard.tsx diff --git a/apps/cursor/src/app/page.tsx b/apps/cursor/src/app/page.tsx index 499d4dce..255a7d82 100644 --- a/apps/cursor/src/app/page.tsx +++ b/apps/cursor/src/app/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import type { PluginCardData } from "@/components/plugins/plugin-card"; +import type { LeaderboardItem } from "@/components/plugins/plugin-leaderboard"; import { Startpage } from "@/components/startpage"; -import { getMembers, getPlugins, getTotalUsers } from "@/data/queries"; +import { getPlugins, getTotalUsers } from "@/data/queries"; export const metadata: Metadata = { title: "Cursor Directory - Plugins for Cursor", @@ -20,80 +20,39 @@ export const metadata: Metadata = { export const dynamic = "force-static"; export const revalidate = 86400; -function getPluginType( - components: { type: string }[], -): "rules" | "mcp" | "both" { - const hasRules = components.some((c) => c.type === "rule"); - const hasMcp = components.some((c) => c.type === "mcp_server"); - if (hasRules && hasMcp) return "both"; - if (hasMcp) return "mcp"; - return "rules"; -} - -function toPluginCard( +function toLeaderboardItem( p: NonNullable>["data"]>[number], -): PluginCardData { - const components = p.plugin_components ?? []; +): LeaderboardItem { return { name: p.name, slug: p.slug, description: p.description ?? "", logo: p.logo, - type: getPluginType(components), - rulesCount: components.filter((c) => c.type === "rule").length, - mcpCount: components.filter((c) => c.type === "mcp_server").length, - keywords: p.keywords, - installCount: p.install_count, + author: p.author_name, + authorUrl: p.author_url, verified: p.verified, + installCount: p.install_count, + starCount: p.star_count, + createdAt: p.created_at, href: `/plugins/${p.slug}`, }; } export default async function Page() { - const [{ data: totalUsers }, { data: members }, { data: allPluginsData }] = - await Promise.all([ - getTotalUsers(), - getMembers({ page: 1, limit: 16 }), - getPlugins({ fetchAll: true }), - ]); - - const allPluginsRaw = allPluginsData ?? []; - - const allPlugins = allPluginsRaw - .map(toPluginCard) - .sort((a, b) => a.name.localeCompare(b.name)); - - const popularPlugins = allPluginsRaw - .filter((p) => p.install_count > 0) - .sort((a, b) => b.install_count - a.install_count) - .slice(0, 12) - .map(toPluginCard); - - const recentPlugins = [...allPluginsRaw] - .sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ) - .slice(0, 20) - .map(toPluginCard); + const [{ data: totalUsers }, { data: allPluginsData }] = await Promise.all([ + getTotalUsers(), + getPlugins({ fetchAll: true }), + ]); - const starredPlugins = allPluginsRaw - .filter((p) => p.star_count > 0) - .sort((a, b) => b.star_count - a.star_count) - .slice(0, 8) - .map(toPluginCard); + const leaderboardItems = (allPluginsData ?? []).map(toLeaderboardItem); return (
diff --git a/apps/cursor/src/components/global-search-input.tsx b/apps/cursor/src/components/global-search-input.tsx index 8da65942..f66b81bd 100644 --- a/apps/cursor/src/components/global-search-input.tsx +++ b/apps/cursor/src/components/global-search-input.tsx @@ -8,7 +8,7 @@ export function GlobalSearchInput() { const [search, setSearch] = useQueryState("q", { defaultValue: "" }); const router = useRouter(); - const placeholder = "Search plugins, MCP servers, events, members..."; + const placeholder = "Search plugins..."; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); diff --git a/apps/cursor/src/components/hero-title.tsx b/apps/cursor/src/components/hero-title.tsx index 42eb3438..0eb163c0 100644 --- a/apps/cursor/src/components/hero-title.tsx +++ b/apps/cursor/src/components/hero-title.tsx @@ -9,18 +9,19 @@ export function HeroTitle({ totalUsers }: { totalUsers: number }) { return (

- Explore what the community is building + Extend Cursor with community plugins.

+ Discover and install{" "} - Plugins + plugins {" "} - and{" "} + from{" "} {formatNumber(totalUsers)}+ developers - {" "} - building with Cursor. + + , ranked by installs.

); diff --git a/apps/cursor/src/components/plugins/plugin-leaderboard.tsx b/apps/cursor/src/components/plugins/plugin-leaderboard.tsx new file mode 100644 index 00000000..66ee6b54 --- /dev/null +++ b/apps/cursor/src/components/plugins/plugin-leaderboard.tsx @@ -0,0 +1,353 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { PluginIconFallback } from "@/components/plugins/plugin-icon"; +import { VerifiedBadge } from "@/components/plugins/verified-badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn, formatCount } from "@/lib/utils"; + +export type LeaderboardItem = { + name: string; + slug: string; + description?: string; + logo?: string | null; + author?: string | null; + authorUrl?: string | null; + verified?: boolean; + installCount: number; + starCount: number; + createdAt: string; + href: string; +}; + +type LeaderboardSort = "installs" | "recent" | "stars"; + +const TABS: { id: LeaderboardSort; label: string }[] = [ + { id: "installs", label: "All Time" }, + { id: "recent", label: "Recent" }, + { id: "stars", label: "Most Starred" }, +]; + +const isSvgLogo = (url: string) => url.endsWith(".svg"); + +function isValidImageUrl(url: string | null | undefined): url is string { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +} + +function metricFor(item: LeaderboardItem, sort: LeaderboardSort): number { + switch (sort) { + case "installs": + return item.installCount; + case "stars": + return item.starCount; + case "recent": + return new Date(item.createdAt).getTime(); + } +} + +function formatRelativeDate(iso: string): string { + const then = new Date(iso).getTime(); + const now = Date.now(); + const diff = Math.max(0, now - then); + const day = 24 * 60 * 60 * 1000; + const days = Math.floor(diff / day); + if (days < 1) return "today"; + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + const years = Math.floor(months / 12); + return `${years}y ago`; +} + +type Row = + | { kind: "item"; rank: number; item: LeaderboardItem } + | { + kind: "more"; + author: string; + count: number; + totalMetric: number; + sort: LeaderboardSort; + }; + +function buildRows( + items: LeaderboardItem[], + sort: LeaderboardSort, + groupByAuthor: boolean, +): Row[] { + const sorted = [...items].sort( + (a, b) => metricFor(b, sort) - metricFor(a, sort), + ); + + if (!groupByAuthor) { + return sorted.map((item, i) => ({ kind: "item", rank: i + 1, item })); + } + + const totalsPerAuthor = new Map(); + const countPerAuthor = new Map(); + for (const it of sorted) { + if (!it.author) continue; + totalsPerAuthor.set( + it.author, + (totalsPerAuthor.get(it.author) ?? 0) + metricFor(it, sort), + ); + countPerAuthor.set(it.author, (countPerAuthor.get(it.author) ?? 0) + 1); + } + + const seen = new Set(); + const rows: Row[] = []; + let rank = 0; + + for (const item of sorted) { + rank += 1; + const author = item.author?.trim() || null; + if (author && seen.has(author)) { + // collapsed into the "+X more" entry shown earlier; rank still + // advances so the next visible item reflects its true position + continue; + } + rows.push({ kind: "item", rank, item }); + if (author) { + seen.add(author); + const total = countPerAuthor.get(author) ?? 1; + if (total > 1) { + rows.push({ + kind: "more", + author, + count: total - 1, + totalMetric: totalsPerAuthor.get(author) ?? 0, + sort, + }); + } + } + } + + return rows; +} + +function PluginLogo({ item }: { item: LeaderboardItem }) { + if (isValidImageUrl(item.logo)) { + return ( + + + + {item.name.charAt(0).toUpperCase()} + + + ); + } + return ; +} + +function ItemRow({ + rank, + item, + display, +}: { + rank: number; + item: LeaderboardItem; + display: string; +}) { + return ( + + + {rank} + + +
+ +
+
+ + {item.name} + + {item.verified ? : null} + {item.author ? ( + + · {item.author} + + ) : null} +
+ {item.description ? ( +

+ {item.description} +

+ ) : null} +
+
+ + + {display} + + + ); +} + +function MoreRow({ + author, + count, + totalMetric, + sort, +}: { + author: string; + count: number; + totalMetric: number; + sort: LeaderboardSort; +}) { + const href = `/plugins?q=${encodeURIComponent(author)}`; + return ( + + + + +{count} more from{" "} + {author} + + {sort === "recent" ? ( + + ) : ( + + {formatCount(totalMetric)} total + + )} + + ); +} + +export function PluginLeaderboard({ + items, + initialSort = "installs", + groupByAuthor = false, + maxItems = 500, + chunkSize = 50, +}: { + items: LeaderboardItem[]; + initialSort?: LeaderboardSort; + groupByAuthor?: boolean; + maxItems?: number; + chunkSize?: number; +}) { + const [sort, setSort] = useState(initialSort); + const [visible, setVisible] = useState(chunkSize); + + const rows = useMemo(() => { + const built = buildRows(items, sort, groupByAuthor); + return built.slice(0, maxItems); + }, [items, sort, groupByAuthor, maxItems]); + + const visibleRows = rows.slice(0, visible); + const hasMore = visible < rows.length; + + const sentinelRef = useRef(null); + + useEffect(() => { + if (!hasMore) return; + const node = sentinelRef.current; + if (!node) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setVisible((v) => Math.min(v + chunkSize, rows.length)); + } + }, + { rootMargin: "600px 0px" }, + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [hasMore, rows.length, chunkSize]); + + const onTabChange = (next: LeaderboardSort) => { + setSort(next); + setVisible(chunkSize); + }; + + return ( +
+
+ {TABS.map((tab) => ( + + ))} +
+ +
+ {visibleRows.map((row) => { + if (row.kind === "more") { + return ( + + ); + } + const display = + sort === "recent" + ? formatRelativeDate(row.item.createdAt) + : formatCount(metricFor(row.item, sort)); + return ( + + ); + })} + + {visibleRows.length === 0 ? ( +
+ No plugins to show yet. +
+ ) : null} +
+ + {hasMore ? ( +
+ ) : ( +
+ + Browse all plugins + +
+ )} +
+ ); +} diff --git a/apps/cursor/src/components/startpage.tsx b/apps/cursor/src/components/startpage.tsx index 4e8b91a3..3a062327 100644 --- a/apps/cursor/src/components/startpage.tsx +++ b/apps/cursor/src/components/startpage.tsx @@ -3,102 +3,30 @@ import Fuse from "fuse.js"; import Link from "next/link"; import { useQueryState } from "nuqs"; -import { useEffect, useMemo, useState } from "react"; -import type { PluginCardData } from "@/components/plugins/plugin-card"; -import { PluginCard } from "@/components/plugins/plugin-card"; +import { useMemo } from "react"; +import type { LeaderboardItem } from "@/components/plugins/plugin-leaderboard"; +import { PluginLeaderboard } from "@/components/plugins/plugin-leaderboard"; import { GlobalSearchInput } from "./global-search-input"; import { HeroTitle } from "./hero-title"; -import { MembersCard } from "./members/members-card"; - -function ArrowIcon() { - return ( - - - - - - - - - ); -} - -function SectionHeader({ - title, - href, - ctaLabel = "View all", -}: { - title: string; - href: string; - ctaLabel?: string; -}) { - return ( -
-

{title}

- - {ctaLabel} - - -
- ); -} - -function PluginGrid({ plugins }: { plugins: PluginCardData[] }) { - return ( -
- {plugins.map((plugin) => ( - - ))} -
- ); -} export function Startpage({ - popularPlugins, - allPlugins, - recentPlugins, - starredPlugins, + leaderboardItems, totalUsers, - members, }: { - popularPlugins: PluginCardData[]; - allPlugins: PluginCardData[]; - recentPlugins: PluginCardData[]; - starredPlugins: PluginCardData[]; + leaderboardItems: LeaderboardItem[]; totalUsers: number; - members: unknown[] | null; }) { const [search] = useQueryState("q", { defaultValue: "" }); - const isSearching = search.length > 0; + const isSearching = search.trim().length > 0; - const pluginFuse = useMemo( + const fuse = useMemo( () => - new Fuse(allPlugins, { + new Fuse(leaderboardItems, { keys: [ { name: "name", weight: 3 }, { name: "slug", weight: 1.5 }, - { name: "keywords", weight: 1.5 }, + { name: "author", weight: 1 }, { name: "description", weight: 0.5 }, ], threshold: 0.35, @@ -106,44 +34,13 @@ export function Startpage({ ignoreLocation: true, minMatchCharLength: 2, }), - [allPlugins], + [leaderboardItems], ); - const filteredPlugins = useMemo(() => { - if (!isSearching) return [] as PluginCardData[]; - return pluginFuse.search(search).map((r) => r.item); - }, [search, isSearching, pluginFuse]); - - const [searchedMembers, setSearchedMembers] = useState(null); - - useEffect(() => { - if (!isSearching) { - setSearchedMembers(null); - return; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => { - fetch(`/api/members?q=${encodeURIComponent(search)}`, { - signal: controller.signal, - }) - .then((r) => r.json()) - .then(({ data }) => setSearchedMembers(data ?? [])) - .catch(() => {}); - }, 300); - - return () => { - clearTimeout(timeout); - controller.abort(); - }; - }, [search, isSearching]); - - const filteredMembers = isSearching ? searchedMembers : members; - - const alphabeticalPlugins = useMemo( - () => allPlugins.slice(0, 24), - [allPlugins], - ); + const visibleItems = useMemo(() => { + if (!isSearching) return leaderboardItems; + return fuse.search(search).map((r) => r.item); + }, [isSearching, fuse, search, leaderboardItems]); return (
@@ -155,87 +52,23 @@ export function Startpage({
- {isSearching && filteredPlugins.length > 0 && ( + {visibleItems.length > 0 ? (
- - -
- )} - - {popularPlugins.length > 0 && !isSearching && ( -
- - -
- )} - - {recentPlugins.length > 0 && !isSearching && ( -
- - +
- )} - - {starredPlugins.length > 0 && !isSearching && ( -
- - -
- )} - - {filteredMembers && filteredMembers.length > 0 && ( -
-
- -

Members

- - - {isSearching ? "See all results" : "View all"} - - -
- -
- {filteredMembers.slice(0, 8).map((member: any) => ( - - ))} -
-
- )} - - {alphabeticalPlugins.length > 0 && !isSearching && ( -
- - + ) : ( +
+

+ No plugins found for "{search}" +

+ + Search all plugins +
)} - - {isSearching && - filteredPlugins.length === 0 && - (!filteredMembers || filteredMembers.length === 0) && ( -
-

- No results found for "{search}" -

- - Search all plugins - -
- )}
From 07de6ffccc3119180b7ea23376a3dbcdb05f6049 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Tue, 12 May 2026 17:51:12 +0200 Subject: [PATCH 2/4] Add real Trending ranking backed by daily install snapshots Adds a velocity-based Trending sort to the homepage leaderboard, sourced from a `plugin_install_snapshots` table populated daily via pg_cron and exposed through the `plugin_install_velocity` SQL function. Real velocity ranks first; a synthetic per-month estimate (lifetime / age) ranks the long tail so the list stays populated before snapshots accumulate. Also drops the redundant author label next to each plugin name and tweaks the hero subhead to match the new ranking story. Co-authored-by: Cursor --- apps/cursor/src/app/page.tsx | 29 ++++- apps/cursor/src/components/hero-title.tsx | 2 +- .../components/plugins/plugin-leaderboard.tsx | 93 ++++++++++++-- apps/cursor/src/data/queries.ts | 25 ++++ .../20260514_plugin_install_snapshots.sql | 121 ++++++++++++++++++ 5 files changed, 251 insertions(+), 19 deletions(-) create mode 100644 supabase/migrations/20260514_plugin_install_snapshots.sql diff --git a/apps/cursor/src/app/page.tsx b/apps/cursor/src/app/page.tsx index 255a7d82..df37eaab 100644 --- a/apps/cursor/src/app/page.tsx +++ b/apps/cursor/src/app/page.tsx @@ -2,7 +2,11 @@ import type { Metadata } from "next"; import { Suspense } from "react"; import type { LeaderboardItem } from "@/components/plugins/plugin-leaderboard"; import { Startpage } from "@/components/startpage"; -import { getPlugins, getTotalUsers } from "@/data/queries"; +import { + getPluginInstallVelocity, + getPlugins, + getTotalUsers, +} from "@/data/queries"; export const metadata: Metadata = { title: "Cursor Directory - Plugins for Cursor", @@ -18,10 +22,14 @@ export const metadata: Metadata = { }; export const dynamic = "force-static"; -export const revalidate = 86400; +// Velocity data refreshes daily via the snapshot cron. Revalidating +// the homepage every hour keeps the leaderboard close to live install +// activity without sacrificing the static cache benefit. +export const revalidate = 3600; function toLeaderboardItem( p: NonNullable>["data"]>[number], + installs30d: number, ): LeaderboardItem { return { name: p.name, @@ -32,19 +40,32 @@ function toLeaderboardItem( authorUrl: p.author_url, verified: p.verified, installCount: p.install_count, + installs30d, starCount: p.star_count, createdAt: p.created_at, + updatedAt: p.updated_at, + permanentlyBlocked: p.permanently_blocked, + flagSeverity: p.flag_severity, + scanStatus: p.scan_status, href: `/plugins/${p.slug}`, }; } export default async function Page() { - const [{ data: totalUsers }, { data: allPluginsData }] = await Promise.all([ + const [ + { data: totalUsers }, + { data: allPluginsData }, + { data: velocityMap }, + ] = await Promise.all([ getTotalUsers(), getPlugins({ fetchAll: true }), + getPluginInstallVelocity(30), ]); - const leaderboardItems = (allPluginsData ?? []).map(toLeaderboardItem); + const velocity = velocityMap ?? new Map(); + const leaderboardItems = (allPluginsData ?? []).map((p) => + toLeaderboardItem(p, velocity.get(p.id) ?? 0), + ); return (
diff --git a/apps/cursor/src/components/hero-title.tsx b/apps/cursor/src/components/hero-title.tsx index 0eb163c0..186e95dc 100644 --- a/apps/cursor/src/components/hero-title.tsx +++ b/apps/cursor/src/components/hero-title.tsx @@ -21,7 +21,7 @@ export function HeroTitle({ totalUsers }: { totalUsers: number }) { {formatNumber(totalUsers)}+ developers - , ranked by installs. + , ranked by what’s trending.

); diff --git a/apps/cursor/src/components/plugins/plugin-leaderboard.tsx b/apps/cursor/src/components/plugins/plugin-leaderboard.tsx index 66ee6b54..418c6fcd 100644 --- a/apps/cursor/src/components/plugins/plugin-leaderboard.tsx +++ b/apps/cursor/src/components/plugins/plugin-leaderboard.tsx @@ -16,19 +16,78 @@ export type LeaderboardItem = { authorUrl?: string | null; verified?: boolean; installCount: number; + installs30d?: number; starCount: number; createdAt: string; + updatedAt?: string; + permanentlyBlocked?: boolean; + flagSeverity?: "low" | "medium" | "high" | null; + scanStatus?: + | "pending" + | "scanning" + | "safe" + | "flagged" + | "error" + | "unscanned"; href: string; }; -type LeaderboardSort = "installs" | "recent" | "stars"; +type LeaderboardSort = "trending" | "installs" | "recent"; const TABS: { id: LeaderboardSort; label: string }[] = [ - { id: "installs", label: "All Time" }, - { id: "recent", label: "Recent" }, - { id: "stars", label: "Most Starred" }, + { id: "trending", label: "Trending" }, + { id: "installs", label: "Top" }, + { id: "recent", label: "New" }, ]; +const DAY_MS = 24 * 60 * 60 * 1000; + +// Hides plugins that should never appear on the public leaderboard, +// regardless of how many installs they have. +function isExcluded(item: LeaderboardItem): boolean { + if (item.permanentlyBlocked) return true; + if (item.flagSeverity === "high") return true; + if (item.scanStatus === "flagged") return true; + return false; +} + +// Estimated 30-day install count for plugins where we don't yet have a +// real velocity signal. Assumes a uniform install rate over the +// plugin's lifetime — an imperfect proxy (real install curves spike +// at launch and taper), but defensible as an *estimate* and clearly +// marked in the UI with a `~` prefix to distinguish from measured +// velocity. +function syntheticVelocity(item: LeaderboardItem): number { + const lifetime = Math.max(0, item.installCount); + if (lifetime === 0) return 0; + const ageDays = Math.max( + 1, + (Date.now() - new Date(item.createdAt).getTime()) / DAY_MS, + ); + return Math.round(lifetime * Math.min(1, 30 / ageDays)); +} + +// Trending = installs in the last 30 days, real or estimated. +// 1. Real velocity wins always (bumped into a range no synthetic +// value can reach via the 1e9 multiplier). +// 2. Synthetic velocity ranks the long tail so Trending stays +// populated before snapshots accumulate. Lifetime breaks ties. +// 3. Plugins with no installs at all are filtered out. +// +// Real velocity is sourced from the daily snapshot pipeline +// (`snapshot_plugin_installs()` scheduled via Supabase Cron / pg_cron, +// exposed by `plugin_install_velocity`). For plugins younger than the +// window, the SQL function returns their full install_count — every +// install they have happened inside the window by definition. +function trendingScore(item: LeaderboardItem): number { + const realVelocity = Math.max(0, item.installs30d ?? 0); + const lifetime = Math.max(0, item.installCount); + if (realVelocity > 0) { + return realVelocity * 1_000_000_000 + lifetime; + } + return syntheticVelocity(item) * 1_000 + lifetime; +} + const isSvgLogo = (url: string) => url.endsWith(".svg"); function isValidImageUrl(url: string | null | undefined): url is string { @@ -43,10 +102,10 @@ function isValidImageUrl(url: string | null | undefined): url is string { function metricFor(item: LeaderboardItem, sort: LeaderboardSort): number { switch (sort) { + case "trending": + return trendingScore(item); case "installs": return item.installCount; - case "stars": - return item.starCount; case "recent": return new Date(item.createdAt).getTime(); } @@ -81,7 +140,18 @@ function buildRows( sort: LeaderboardSort, groupByAuthor: boolean, ): Row[] { - const sorted = [...items].sort( + const safeItems = items.filter((i) => !isExcluded(i)); + // Trending requires *some* signal: either real recent installs, or + // at least a positive lifetime install_count (so we can compute a + // synthetic per-month estimate from the install rate). Plugins with + // zero installs ever are not "trending". + const candidates = + sort === "trending" + ? safeItems.filter( + (i) => (i.installs30d ?? 0) > 0 || i.installCount > 0, + ) + : safeItems; + const sorted = [...candidates].sort( (a, b) => metricFor(b, sort) - metricFor(a, sort), ); @@ -175,11 +245,6 @@ function ItemRow({ {item.name} {item.verified ? : null} - {item.author ? ( - - · {item.author} - - ) : null} {item.description ? (

@@ -231,7 +296,7 @@ function MoreRow({ export function PluginLeaderboard({ items, - initialSort = "installs", + initialSort = "trending", groupByAuthor = false, maxItems = 500, chunkSize = 50, @@ -314,7 +379,7 @@ export function PluginLeaderboard({ const display = sort === "recent" ? formatRelativeDate(row.item.createdAt) - : formatCount(metricFor(row.item, sort)); + : formatCount(row.item.installCount); return ( , derived +// from `plugin_install_snapshots` via the `plugin_install_velocity` SQL +// function. Plugins with no snapshot history yet (or no fresh installs) +// will simply be absent from the map; callers should default to 0. +export async function getPluginInstallVelocity(windowDays = 30): Promise<{ + data: Map | null; + error: unknown; +}> { + const supabase = await createClient(); + const { data, error } = await supabase.rpc("plugin_install_velocity", { + window_days: windowDays, + }); + + if (error) return { data: null, error }; + + const map = new Map(); + for (const row of (data ?? []) as { + plugin_id: string; + installs_window: number; + }[]) { + map.set(row.plugin_id, row.installs_window); + } + return { data: map, error: null }; +} + export async function getPluginBySlug(slug: string) { const supabase = await createClient(); const { data, error } = await supabase diff --git a/supabase/migrations/20260514_plugin_install_snapshots.sql b/supabase/migrations/20260514_plugin_install_snapshots.sql new file mode 100644 index 00000000..abc5ee01 --- /dev/null +++ b/supabase/migrations/20260514_plugin_install_snapshots.sql @@ -0,0 +1,121 @@ +-- Daily snapshots of `plugins.install_count` so we can rank by recent +-- velocity (e.g. "Trending = installs over the last 30 days") instead +-- of relying solely on lifetime totals, which over-favor older plugins. +-- +-- Populated by `snapshot_plugin_installs()` (defined below), which is +-- scheduled to run daily by Supabase Cron / pg_cron. No application +-- code or HTTP route is involved — the snapshot lives entirely in the +-- database layer. One row per (plugin_id, snapshot_date), and rows +-- older than ~400 days are pruned so the table stays bounded. + +create table if not exists plugin_install_snapshots ( + plugin_id uuid not null references plugins(id) on delete cascade, + snapshot_date date not null, + install_count integer not null, + primary key (plugin_id, snapshot_date) +); + +create index if not exists plugin_install_snapshots_date_idx + on plugin_install_snapshots (snapshot_date desc); + +-- Returns each active plugin's install velocity over the last +-- `window_days` days. Two cases: +-- +-- 1. Plugin is younger than the window: every install necessarily +-- happened within the window, so velocity = install_count. This +-- lets the Trending leaderboard surface brand-new plugins gaining +-- traction without waiting a full month of snapshot history. +-- +-- 2. Plugin is older than the window: velocity = current install_count +-- minus the install_count from `window_days` ago. Until we have a +-- snapshot that old, we fall back to the earliest snapshot we have +-- so velocity ramps up gracefully rather than reporting zero. +-- +-- Negative deltas (e.g. an install_count reset) are clamped to 0. +create or replace function plugin_install_velocity(window_days int default 30) +returns table (plugin_id uuid, installs_window int) +language sql +stable +as $$ + with target as ( + select + p.id as plugin_id, + p.install_count as current_count, + p.created_at::date as created_date, + coalesce( + ( + select s.install_count + from plugin_install_snapshots s + where s.plugin_id = p.id + and s.snapshot_date <= (current_date - window_days) + order by s.snapshot_date desc + limit 1 + ), + ( + select s.install_count + from plugin_install_snapshots s + where s.plugin_id = p.id + order by s.snapshot_date asc + limit 1 + ) + ) as baseline + from plugins p + where p.active = true + ) + select + plugin_id, + case + when created_date >= (current_date - window_days) then current_count + else greatest(current_count - coalesce(baseline, current_count), 0)::int + end as installs_window + from target; +$$; + +-- Snapshot routine: idempotent on (plugin_id, snapshot_date) so safe to +-- re-run within the same day. Active plugins only — soft-deleted plugins +-- shouldn't accumulate rows. Pruning runs in the same call so retention +-- can never silently drift. +create or replace function snapshot_plugin_installs() +returns void +language plpgsql +as $$ +begin + insert into plugin_install_snapshots (plugin_id, snapshot_date, install_count) + select id, current_date, install_count + from plugins + where active = true + on conflict (plugin_id, snapshot_date) do update + set install_count = excluded.install_count; + + delete from plugin_install_snapshots + where snapshot_date < current_date - interval '400 days'; +end; +$$; + +-- Schedule the snapshot via Supabase Cron (pg_cron under the hood). +-- Runs daily at 00:05 UTC. No HTTP route, no CRON_SECRET, no Vercel +-- coupling: the schedule lives in the database alongside the data. +-- +-- Requires the `pg_cron` extension. Supabase ships with it preinstalled +-- but disabled; this enables it. If the role running the migration +-- can't enable extensions (e.g. local dev without superuser), enable +-- it once via Supabase Dashboard → Database → Extensions → pg_cron and +-- comment the next line out. +create extension if not exists pg_cron; + +-- Idempotent: drop any previous schedule before recreating, so this +-- migration can be re-applied without raising "duplicate jobname". +do $$ +begin + if exists ( + select 1 from cron.job where jobname = 'plugin-install-daily-snapshot' + ) then + perform cron.unschedule('plugin-install-daily-snapshot'); + end if; +end$$; + +select cron.schedule( + 'plugin-install-daily-snapshot', + '5 0 * * *', + $$ select snapshot_plugin_installs(); $$ +); From ae72ed5ad19cf5fc40402f5306987fc4260d5a1f Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Tue, 12 May 2026 22:25:54 +0200 Subject: [PATCH 3/4] Fix plugin security scan: clone-then-local agent + workflow patch - Switch runSecurityAgent from the Cursor cloud runtime to local runtime. Clone the submitted public repo to /tmp via `git clone --depth=1 --single-branch --filter=blob:limit=10m`, run the agent against it with `local: { cwd }`, clean up in a finally. The cloud runtime requires the Cursor GitHub App to be installed on each scanned repo, which can't work for a public marketplace where authors submit arbitrary repos we don't own. Clone failures degrade to a non-fatal manual-review verdict instead of crashing the workflow. - Patch @workflow/world@4.1.1 with the upstream Zod 4.4.x fix (vercel/workflow#1902) so workflow runs can persist state under our pinned zod@4.4.3. The local storage round-trips through JSON.stringify (which strips undefined keys), and zod@4.4.x's strict treatment of z.undefined() then rejects the read. Drop the patch once a fixed @workflow/world release lands on npm. - Sync apps/cursor/.env.example to what the code actually reads: add CURSOR_API_KEY, GITHUB_TOKEN, NEXT_PUBLIC_OPENPANEL_CLIENT_ID; drop unused MCP_OWNER_ID and NEXT_PUBLIC_APP_URL. Co-authored-by: Cursor --- apps/cursor/.env.example | 37 +++++-- apps/cursor/src/workflows/scan-plugin.ts | 131 +++++++++++++++++------ bun.lock | 3 + package.json | 3 + patches/@workflow%2Fworld@4.1.1.patch | 80 ++++++++++++++ 5 files changed, 212 insertions(+), 42 deletions(-) create mode 100644 patches/@workflow%2Fworld@4.1.1.patch diff --git a/apps/cursor/.env.example b/apps/cursor/.env.example index 7c6f0406..b1fd66b3 100644 --- a/apps/cursor/.env.example +++ b/apps/cursor/.env.example @@ -1,26 +1,45 @@ +# Supabase NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= SUPABASE_SECRET_KEY= -RESEND_API_KEY= - -NEXT_PUBLIC_APP_URL=http://localhost:3000 - -MCP_OWNER_ID= +# Email (Resend) +RESEND_API_KEY= +# Admin allow-list (comma-separated Supabase user IDs). +# NEXT_PUBLIC_ADMIN_USER_IDS mirrors ADMIN_USER_IDS to the browser so admin-only +# UI (e.g. the verify controls on a plugin page) can render conditionally. +# Server-side enforcement still uses ADMIN_USER_IDS — the public copy is purely +# for UI gating. ADMIN_USER_IDS= -# Mirror of ADMIN_USER_IDS exposed to the browser so admin-only UI (e.g. the -# verify controls on a plugin page) can render conditionally. Server-side -# enforcement still uses ADMIN_USER_IDS — this is purely for UI gating. NEXT_PUBLIC_ADMIN_USER_IDS= +# Upstash Redis — backs the rate limiters in src/lib/rate-limit.ts +# (install throttling + per-user plugin-scan budget). Read implicitly by +# `Redis.fromEnv()`. UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= +# Cursor SDK — required for the plugin security scan workflow +# (src/workflows/scan-plugin.ts). Mint a key at +# https://cursor.com/dashboard/cloud-agents or use a team service-account key. +CURSOR_API_KEY= + +# GitHub API token — optional. Without it, unauthenticated GitHub requests +# from the plugin parser and extract scripts are limited to 60/hr. +GITHUB_TOKEN= + +# OpenPanel analytics (disabled automatically when NODE_ENV=development). +NEXT_PUBLIC_OPENPANEL_CLIENT_ID= + +# Airtable — source for the ambassadors cron sync +# (src/app/api/cron/sync-ambassadors/route.ts). Only required if you run that +# cron locally. AIRTABLE_API_KEY= AIRTABLE_BASE_ID= AIRTABLE_AMBASSADORS_TABLE=Directory # Comma-separated list of field names to read emails from (case-sensitive). AIRTABLE_AMBASSADORS_EMAIL_FIELD=Email,Cursor email -CRON_SECRET= \ No newline at end of file +# Shared secret guarding /api/cron/* routes against unauthenticated callers. +CRON_SECRET= diff --git a/apps/cursor/src/workflows/scan-plugin.ts b/apps/cursor/src/workflows/scan-plugin.ts index 321061eb..c966b693 100644 --- a/apps/cursor/src/workflows/scan-plugin.ts +++ b/apps/cursor/src/workflows/scan-plugin.ts @@ -1,3 +1,8 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; import { Agent, CursorAgentError, type RunResult } from "@cursor/sdk"; import { FatalError } from "workflow"; import { z } from "zod"; @@ -9,6 +14,8 @@ import type { } from "@/data/queries"; import { createClient } from "@/utils/supabase/admin-client"; +const execFileAsync = promisify(execFile); + type ComponentRow = Pick< PluginComponent, "type" | "name" | "slug" | "description" | "content" | "metadata" @@ -148,6 +155,39 @@ async function applyBlockedShortCircuit(pluginId: string) { .eq("id", pluginId); } +// Cap how much of the repo we materialize on the function's tmpfs. Plugins +// are typically rules/.md/.json — kilobytes — but we share /tmp with other +// step executions and have a hard ~500MB limit on Vercel. +const CLONE_TIMEOUT_MS = 60_000; +const CLONE_MAX_BUFFER = 4 * 1024 * 1024; + +async function cloneRepo( + owner: string, + repo: string, +): Promise<{ cwd: string; cleanup: () => Promise }> { + const cwd = await mkdtemp(path.join(tmpdir(), "plugin-scan-")); + const cleanup = () => + rm(cwd, { recursive: true, force: true }).catch(() => {}); + try { + await execFileAsync( + "git", + [ + "clone", + "--depth=1", + "--single-branch", + "--filter=blob:limit=10m", + `https://github.com/${owner}/${repo}.git`, + cwd, + ], + { timeout: CLONE_TIMEOUT_MS, maxBuffer: CLONE_MAX_BUFFER }, + ); + return { cwd, cleanup }; + } catch (err) { + await cleanup(); + throw err; + } +} + async function runSecurityAgent(plugin: ScanInput): Promise { "use step"; @@ -161,45 +201,70 @@ async function runSecurityAgent(plugin: ScanInput): Promise { const repoMatch = plugin.repository ? parseGitHubUrl(plugin.repository) : null; - const cloudOptions = repoMatch - ? { - repos: [ - { - url: `https://github.com/${repoMatch.owner}/${repoMatch.repo}`, - startingRef: "main" as const, - }, - ], - } - : {}; - - const prompt = buildPrompt(plugin, { hasRepo: Boolean(repoMatch) }); - let result: RunResult; + // The agent runs in `local` mode against a scratch dir on the function's + // filesystem: either a fresh clone of the user's public repo, or an empty + // dir when no repo URL was supplied. This deliberately avoids the cloud + // runtime's GitHub-App-scoped repo permissions, which would require every + // plugin submitter to install Cursor's GitHub App on their repo — not a + // workable UX for a public marketplace. + let cwd: string; + let cleanup: () => Promise; + let hasRepo = false; try { - result = await Agent.prompt(prompt, { - apiKey, - model: { id: "composer-2" }, - cloud: cloudOptions, - name: `scan:${plugin.slug}`, - }); - } catch (err) { - if (err instanceof CursorAgentError && err.isRetryable) { - // Step retries handle this for us. - throw err; + if (repoMatch) { + const cloned = await cloneRepo(repoMatch.owner, repoMatch.repo); + cwd = cloned.cwd; + cleanup = cloned.cleanup; + hasRepo = true; + } else { + cwd = await mkdtemp(path.join(tmpdir(), "plugin-scan-no-repo-")); + cleanup = () => + rm(cwd, { recursive: true, force: true }).catch(() => {}); } - throw new FatalError( - `Cursor SDK startup failed: ${err instanceof Error ? err.message : String(err)}`, - ); + } catch (err) { + return { + verdict: "suspicious", + severity: "low", + categories: [], + reasons: ["repository_clone_failed"], + summary: `Could not clone ${plugin.repository}: ${err instanceof Error ? err.message : String(err)}. Manual review required.`, + runId: null, + }; } - if (result.status !== "finished") { - throw new FatalError( - `Scan run ${result.id} ended with status=${result.status}`, - ); - } + try { + const prompt = buildPrompt(plugin, { hasRepo }); + + let result: RunResult; + try { + result = await Agent.prompt(prompt, { + apiKey, + model: { id: "composer-2" }, + local: { cwd }, + name: `scan:${plugin.slug}`, + }); + } catch (err) { + if (err instanceof CursorAgentError && err.isRetryable) { + // Step retries handle this for us. + throw err; + } + throw new FatalError( + `Cursor SDK startup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } - const verdict = parseVerdict(result.result ?? ""); - return { ...verdict, runId: result.id }; + if (result.status !== "finished") { + throw new FatalError( + `Scan run ${result.id} ended with status=${result.status}`, + ); + } + + const verdict = parseVerdict(result.result ?? ""); + return { ...verdict, runId: result.id }; + } finally { + await cleanup(); + } } function buildPrompt(plugin: ScanInput, opts: { hasRepo: boolean }) { diff --git a/bun.lock b/bun.lock index fef25b66..dbb87ce5 100644 --- a/bun.lock +++ b/bun.lock @@ -106,6 +106,9 @@ }, }, }, + "patchedDependencies": { + "@workflow/world@4.1.1": "patches/@workflow%2Fworld@4.1.1.patch", + }, "overrides": { "zod": "4.4.3", }, diff --git a/package.json b/package.json index 92e46f1e..41729514 100644 --- a/package.json +++ b/package.json @@ -12,5 +12,8 @@ }, "resolutions": { "zod": "4.4.3" + }, + "patchedDependencies": { + "@workflow/world@4.1.1": "patches/@workflow%2Fworld@4.1.1.patch" } } diff --git a/patches/@workflow%2Fworld@4.1.1.patch b/patches/@workflow%2Fworld@4.1.1.patch new file mode 100644 index 00000000..dfc3bd02 --- /dev/null +++ b/patches/@workflow%2Fworld@4.1.1.patch @@ -0,0 +1,80 @@ +diff --git a/dist/runs.js b/dist/runs.js +index 2332f9442c6c2db70882889b99880d91f79c1790..a41bfafa4adc9b1d9927006ad1dbf122f6a72da6 100644 +--- a/dist/runs.js ++++ b/dist/runs.js +@@ -59,28 +59,28 @@ export const WorkflowRunSchema = z.discriminatedUnion('status', [ + // Non-final states + WorkflowRunBaseSchema.extend({ + status: z.enum(['pending', 'running']), +- output: z.undefined(), +- error: z.undefined(), +- completedAt: z.undefined(), ++ output: z.undefined().optional(), ++ error: z.undefined().optional(), ++ completedAt: z.undefined().optional(), + }), + // Cancelled state + WorkflowRunBaseSchema.extend({ + status: z.literal('cancelled'), +- output: z.undefined(), +- error: z.undefined(), ++ output: z.undefined().optional(), ++ error: z.undefined().optional(), + completedAt: z.coerce.date(), + }), + // Completed state - output can be v1 or v2 format + WorkflowRunBaseSchema.extend({ + status: z.literal('completed'), + output: SerializedDataSchema, +- error: z.undefined(), ++ error: z.undefined().optional(), + completedAt: z.coerce.date(), + }), + // Failed state + WorkflowRunBaseSchema.extend({ + status: z.literal('failed'), +- output: z.undefined(), ++ output: z.undefined().optional(), + error: StructuredErrorSchema, + completedAt: z.coerce.date(), + }), +diff --git a/src/runs.ts b/src/runs.ts +index c9fa9d147cd61c3b8ef722566a910d4173ea3ec9..9205e225f13f0ff29fc03a2cadce70d7f17adc88 100644 +--- a/src/runs.ts ++++ b/src/runs.ts +@@ -66,28 +66,28 @@ export const WorkflowRunSchema = z.discriminatedUnion('status', [ + // Non-final states + WorkflowRunBaseSchema.extend({ + status: z.enum(['pending', 'running']), +- output: z.undefined(), +- error: z.undefined(), +- completedAt: z.undefined(), ++ output: z.undefined().optional(), ++ error: z.undefined().optional(), ++ completedAt: z.undefined().optional(), + }), + // Cancelled state + WorkflowRunBaseSchema.extend({ + status: z.literal('cancelled'), +- output: z.undefined(), +- error: z.undefined(), ++ output: z.undefined().optional(), ++ error: z.undefined().optional(), + completedAt: z.coerce.date(), + }), + // Completed state - output can be v1 or v2 format + WorkflowRunBaseSchema.extend({ + status: z.literal('completed'), + output: SerializedDataSchema, +- error: z.undefined(), ++ error: z.undefined().optional(), + completedAt: z.coerce.date(), + }), + // Failed state + WorkflowRunBaseSchema.extend({ + status: z.literal('failed'), +- output: z.undefined(), ++ output: z.undefined().optional(), + error: StructuredErrorSchema, + completedAt: z.coerce.date(), + }), From 137507b40e0936d38e8f0a6d4a90a5c151f0d84d Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Wed, 13 May 2026 09:32:58 +0200 Subject: [PATCH 4/4] Trim public flagged-plugin banner to minimal copy The agent's full reasoning (summary + reasons list) was visible to anyone who hit the URL of a hidden plugin directly. Keep that detail for the owner so they know what to fix, but show the public only a one-line "pending manual review" headline that signals the submission is being triaged rather than permanently rejected. Co-authored-by: Cursor --- apps/cursor/src/components/plugins/plugin-detail.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cursor/src/components/plugins/plugin-detail.tsx b/apps/cursor/src/components/plugins/plugin-detail.tsx index b8f9be0e..d2135ff4 100644 --- a/apps/cursor/src/components/plugins/plugin-detail.tsx +++ b/apps/cursor/src/components/plugins/plugin-detail.tsx @@ -137,15 +137,15 @@ function ScanStatusBanner({

{live - ? "Flagged by the security agent — under review." - : "Flagged by the security agent and hidden from the directory."} + ? "Flagged by the security agent — pending manual review." + : "Flagged by the security agent. Hidden from the directory pending manual review."}

- {plugin.flag_summary && ( + {isOwner && plugin.flag_summary && (

{plugin.flag_summary}

)} - {reasons.length > 0 && ( + {isOwner && reasons.length > 0 && (
    {reasons.map((reason) => (
  • • {reason}