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/app/page.tsx b/apps/cursor/src/app/page.tsx index 499d4dce..df37eaab 100644 --- a/apps/cursor/src/app/page.tsx +++ b/apps/cursor/src/app/page.tsx @@ -1,8 +1,12 @@ 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 { + getPluginInstallVelocity, + getPlugins, + getTotalUsers, +} from "@/data/queries"; export const metadata: Metadata = { title: "Cursor Directory - Plugins for Cursor", @@ -18,82 +22,58 @@ 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 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 ?? []; + installs30d: number, +): 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, + 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: members }, { data: allPluginsData }] = - await Promise.all([ - getTotalUsers(), - getMembers({ page: 1, limit: 16 }), - getPlugins({ fetchAll: true }), - ]); - - const allPluginsRaw = allPluginsData ?? []; + const [ + { data: totalUsers }, + { data: allPluginsData }, + { data: velocityMap }, + ] = await Promise.all([ + getTotalUsers(), + getPlugins({ fetchAll: true }), + getPluginInstallVelocity(30), + ]); - 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 starredPlugins = allPluginsRaw - .filter((p) => p.star_count > 0) - .sort((a, b) => b.star_count - a.star_count) - .slice(0, 8) - .map(toPluginCard); + 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/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..186e95dc 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 what’s trending.

); 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}
  • 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..418c6fcd --- /dev/null +++ b/apps/cursor/src/components/plugins/plugin-leaderboard.tsx @@ -0,0 +1,418 @@ +"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; + 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 = "trending" | "installs" | "recent"; + +const TABS: { id: LeaderboardSort; label: string }[] = [ + { 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 { + 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 "trending": + return trendingScore(item); + case "installs": + return item.installCount; + 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 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), + ); + + 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.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 = "trending", + 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(row.item.installCount); + 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 - -
    - )}
diff --git a/apps/cursor/src/data/queries.ts b/apps/cursor/src/data/queries.ts index 35dd04a4..004dae9d 100644 --- a/apps/cursor/src/data/queries.ts +++ b/apps/cursor/src/data/queries.ts @@ -365,6 +365,31 @@ export async function getPlugins({ return { data: data as PluginRow[] | null, error }; } +// Returns a Map, 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/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(), + }), 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(); $$ +);