diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9295e1d9..d0eb869f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: checks: name: Lint & typecheck diff --git a/AGENTS.md b/AGENTS.md index ec958a4c..ce3457fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Lint from repo root: `bunx biome ci .` (matches CI in `.github/workflows/ci.yml` - App env lives in `apps/cursor/.env` (gitignored). For local dev it uses the Supabase CLI's well-known local default keys (`supabase status` prints them) with `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321`. `CRON_SECRET` guards `/api/queue/plugin-scans/drain` and `/api/cron/*`. - The login page only offers GitHub/Google OAuth, which are not configured locally. Create a confirmed user via the GoTrue admin API (`POST {SUPABASE_URL}/auth/v1/admin/users` with the secret key, `"email_confirm": true`); a DB trigger provisions the `public.users` profile row. To get a browser session, add a temporary route that calls `supabase.auth.signInWithPassword(...)` with the server client (do not commit it). To use `/admin/plugins` and the MCP server, add the user to the `public.admins` table: `insert into public.admins (user_id) values ('')`. Email rows only match Google/GitHub identities, so local password users need a `user_id` row. - Cache Components (`cacheComponents: true`) makes pages with `generateStaticParams` throw 500 on an empty database — seed at least one active plugin. Use `bun run seed:extract` / `seed:insert`, but note: the insert script must be run as `bun run --conditions=react-server --env-file=apps/cursor/.env apps/cursor/src/scripts/insert-from-jsonl.ts` or the `server-only` import throws under plain `bun run`. GitHub Code Search (used by `seed:extract` discovery) is heavily rate-limited; hand-writing `apps/cursor/.seed/candidates.json` with `{"candidates":[{"owner":...,"repo":...,"source":"seed:topic","matchedQuery":"manual"}]}` skips discovery. -- Seeded plugin logos hosted on `raw.githubusercontent.com` 500 plugin pages because that host is not in `next.config.mjs` `images.remotePatterns`; null them: `update plugins set logo = null where logo like 'https://raw.githubusercontent.com%'`. +- Plugin logos are rendered `unoptimized` in `PluginLogo`, so hosts outside `next.config.mjs` `images.remotePatterns` (for example `raw.githubusercontent.com` for manifests with a relative `logo`) no longer 500 the plugin page. Only `next/image` usages that go through the optimizer need the allowlist. - After changing `.env` or database content backing cached pages, restart `bun dev` (and `rm -rf apps/cursor/.next` if stale renders persist) — hot reload does not invalidate Cache Components output. - Without `CURSOR_API_KEY`, plugin submission works but the security scan errors (plugin lands in the admin "Scan issues" queue, where an admin can publish it manually). This is expected locally. - Supabase `edge_runtime` is disabled in `supabase/config.toml` (no edge functions in this app; its boot probe needs deno.land which is blocked). diff --git a/README.md b/README.md index 6dc43fc5..80149f38 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ All content is submitted through the website — no pull requests needed for dat 1. Go to [cursor.directory/plugins/new](https://cursor.directory/plugins/new) 2. Sign in with GitHub or Google -3. Paste a GitHub repo URL — we auto-detect components following the [Open Plugins](https://open-plugins.com) standard +3. Paste a GitHub repo URL — we auto-detect components following the [Open Plugins](https://agent-plugins.org) standard 4. Click **Submit** Auto-detected components: @@ -85,13 +85,16 @@ Auto-detected components: | Component | Path | |-----------|------| | Rules | `rules/*.mdc` | -| MCP Servers | `.mcp.json` | +| MCP Servers | `.mcp.json` or `mcp.json` | | Skills | `skills/*/SKILL.md` | | Agents | `agents/*.md` | +| Commands | `commands/*.md` | | Hooks | `hooks/hooks.json` | | LSP Servers | `.lsp.json` | -See the [Open Plugins specification](https://open-plugins.com/plugin-builders/specification) and [plugin template](https://github.com/cursor/plugin-template) for details. +The manifest is read from `.plugin/plugin.json`, `.cursor-plugin/plugin.json` or `.claude-plugin/plugin.json` (first match wins). For monorepos, the same paths are also scanned inside every directory listed as `plugins[].source` in `.cursor-plugin/marketplace.json` and every directory that contains its own `.cursor-plugin/plugin.json`. + +See the [Open Plugins specification](https://agent-plugins.org/specification) and [plugin template](https://github.com/cursor/plugin-template) for details. --- @@ -104,7 +107,7 @@ See the [Open Plugins specification](https://open-plugins.com/plugin-builders/sp - **Styling**: [Tailwind CSS](https://tailwindcss.com) - **UI**: [Radix UI](https://radix-ui.com) + [shadcn/ui](https://ui.shadcn.com) - **Search**: [Fuse.js](https://fusejs.io) (client-side fuzzy search) -- **URL State**: [nuqs](https://nuqs.47ng.com) +- **URL State**: [nuqs](https://nuqs.dev) - **Linting**: [Biome](https://biomejs.dev) ## Plugin security scan diff --git a/apps/cursor/package.json b/apps/cursor/package.json index a73647c6..3df01a60 100644 --- a/apps/cursor/package.json +++ b/apps/cursor/package.json @@ -9,7 +9,7 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "biome check .", "typecheck": "tsc --noEmit", "seed:extract": "bun run --env-file=.env src/scripts/extract-from-github.ts", "seed:insert": "bun run --env-file=.env src/scripts/insert-from-jsonl.ts", @@ -60,7 +60,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@biomejs/biome": "2.4.14", + "@biomejs/biome": "2.4.15", "@tailwindcss/postcss": "^4.2.4", "@types/node": "^25.6.0", "@types/react": "^19", diff --git a/apps/cursor/src/actions/delete-plugin.ts b/apps/cursor/src/actions/delete-plugin.ts index 1b74e971..5c6737d6 100644 --- a/apps/cursor/src/actions/delete-plugin.ts +++ b/apps/cursor/src/actions/delete-plugin.ts @@ -33,16 +33,26 @@ export const deletePluginAction = authActionClient ); } - const { error } = await supabase + const { data: deleted, error } = await supabase .from("plugins") .delete() .eq("id", id) - .eq("owner_id", userId); + .eq("owner_id", userId) + .select("id"); if (error) { throw new ActionError(`Failed to delete plugin: ${error.message}`); } + // A delete that matches no rows is not an error for PostgREST, so without + // this check a row the policy refuses to delete would still produce the + // "Plugin deleted." toast while staying live and keeping its slug. + if (!deleted || deleted.length === 0) { + throw new ActionError( + "The plugin could not be deleted. It is still listed; contact support to have it removed.", + ); + } + // Deletions must disappear from cached lists immediately for the owner. updateTag("plugins"); updateTag(`plugin-${existing.slug}`); diff --git a/apps/cursor/src/actions/rescan-own-plugin.ts b/apps/cursor/src/actions/rescan-own-plugin.ts new file mode 100644 index 00000000..888fcd49 --- /dev/null +++ b/apps/cursor/src/actions/rescan-own-plugin.ts @@ -0,0 +1,80 @@ +"use server"; + +import { z } from "zod"; +import { + ModerationError, + rescanPlugin, + webModerationContext, +} from "@/lib/plugins/moderation"; +import { pluginScanLimit } from "@/lib/rate-limit"; +import { createClient } from "@/utils/supabase/server"; +import { ActionError, authActionClient } from "./safe-action"; + +/** + * Scan states an owner may re-run themselves. `pending` and `scanning` are + * already in flight; `safe` has nothing to fix; `permanently_blocked` rows are + * excluded below regardless of status. + */ +const OWNER_RESCANNABLE = new Set(["flagged", "error", "unscanned"]); + +/** + * Owner-initiated re-scan. Same path as the admin "Rescan" (status reset, + * queue, audit event with the owner as actor), gated by ownership, the + * permanent block, and the per-user scan rate limit that already caps + * create/update scans. + */ +export const rescanOwnPluginAction = authActionClient + .metadata({ actionName: "rescan-own-plugin" }) + .schema(z.object({ pluginId: z.string().uuid() })) + .action(async ({ parsedInput: { pluginId }, ctx: { userId } }) => { + const supabase = await createClient(); + + const { data: existing, error: fetchError } = await supabase + .from("plugins") + .select("id, owner_id, scan_status, permanently_blocked") + .eq("id", pluginId) + .single(); + + if (fetchError || !existing) { + throw new ActionError("Plugin not found."); + } + + if (existing.owner_id !== userId) { + throw new ActionError( + "You do not have permission to update this plugin.", + ); + } + + if (existing.permanently_blocked) { + throw new ActionError( + "This plugin has been blocked and cannot be re-scanned. Contact support if you believe this is a mistake.", + ); + } + + if (!OWNER_RESCANNABLE.has(existing.scan_status)) { + throw new ActionError( + existing.scan_status === "safe" + ? "This plugin already passed its last scan." + : "A scan is already in progress.", + ); + } + + const { success } = await pluginScanLimit(userId); + if (!success) { + throw new ActionError( + "Too many scans in the last hour. Please try again later.", + ); + } + + try { + await rescanPlugin(webModerationContext(userId), pluginId); + } catch (err) { + throw new ActionError( + err instanceof ModerationError + ? err.message + : "Failed to start the scan.", + ); + } + + return { success: true }; + }); diff --git a/apps/cursor/src/app/plugins/new/page.tsx b/apps/cursor/src/app/plugins/new/page.tsx index 116690e0..a51b2ca9 100644 --- a/apps/cursor/src/app/plugins/new/page.tsx +++ b/apps/cursor/src/app/plugins/new/page.tsx @@ -46,7 +46,7 @@ export default function Page() {
We follow the{" "} , + fallbackName: string, ): { name: string; config: Record } | null { // Try content first, then metadata.config let parsed: Record | null = null; @@ -43,8 +44,13 @@ function resolveMcpConfig( } } - // Content is already a raw config (no mcpServers wrapper) - return { name: (meta?.name as string) ?? "server", config: parsed }; + // Content is already a raw config (no mcpServers wrapper). Ingestion strips + // the wrapper, so fall back to the component name before the generic default + // -- otherwise every such server installs as literally "server". + return { + name: (meta?.name as string) ?? fallbackName ?? "server", + config: parsed, + }; } export function McpSection({ @@ -70,10 +76,15 @@ export function McpSection({ let installLink = mcpLink ?? null; let configPreview: string | null = null; try { - const resolved = resolveMcpConfig(mcp.content, meta); + const resolved = resolveMcpConfig(mcp.content, meta, mcp.name); if (resolved) { - const serialized = JSON.stringify(resolved.config, null, 2); - configPreview = serialized; + // Show the wrapped form: this is what a user pastes into + // ~/.cursor/mcp.json. The bare inner object is not valid there. + configPreview = JSON.stringify( + { mcpServers: { [resolved.name]: resolved.config } }, + null, + 2, + ); if (!installLink) { installLink = buildMCPInstallDeepLink( resolved.name, @@ -133,8 +144,11 @@ export function McpSection({ mcp_link={installLink} onInstall={onInstall} /> - ) : installable && mcp.content ? ( - + ) : installable && (configPreview ?? mcp.content) ? ( + ) : null} @@ -142,8 +156,9 @@ export function McpSection({ {isExpanded && configPreview && (

- This config will be passed to Cursor on install. Inspect - `command`, `args`, and `env` before continuing. + This config will be passed to Cursor on install, and is what + you paste into `~/.cursor/mcp.json` to add the server by hand. + Inspect `command`, `args`, and `env` before continuing.

diff --git a/apps/cursor/src/components/plugins/detail/plugin-logo.tsx b/apps/cursor/src/components/plugins/detail/plugin-logo.tsx index 34aa0fda..0ca65d9d 100644 --- a/apps/cursor/src/components/plugins/detail/plugin-logo.tsx +++ b/apps/cursor/src/components/plugins/detail/plugin-logo.tsx @@ -42,6 +42,12 @@ export function PluginLogo({ logo.endsWith(".svg") && "invert", )} onError={() => setError(true)} + // Logos come from whatever host the plugin manifest points at (raw + // GitHub content for relative paths, anything for absolute URLs), so + // the image optimizer's remotePatterns allowlist would 500 the page + // for most of them. The card and leaderboard already render the same + // URLs as plain ; do the same here. + unoptimized /> ); } diff --git a/apps/cursor/src/components/plugins/plugin-owner-menu.tsx b/apps/cursor/src/components/plugins/plugin-owner-menu.tsx index 0dfd589b..ee257c8f 100644 --- a/apps/cursor/src/components/plugins/plugin-owner-menu.tsx +++ b/apps/cursor/src/components/plugins/plugin-owner-menu.tsx @@ -7,6 +7,7 @@ import { Loader2, MoreHorizontal, Pencil, + RefreshCw, Trash2, } from "lucide-react"; import Link from "next/link"; @@ -16,6 +17,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { deletePluginAction } from "@/actions/delete-plugin"; import { requestPluginVerificationAction } from "@/actions/request-plugin-verification"; +import { rescanOwnPluginAction } from "@/actions/rescan-own-plugin"; import { togglePluginListingAction } from "@/actions/toggle-plugin-listing"; import { AlertDialog, @@ -106,6 +108,19 @@ export function PluginOwnerMenu({ plugin }: Props) { }, ); + const { execute: rescanPlugin, isExecuting: isRescanning } = useAction( + rescanOwnPluginAction, + { + onSuccess: () => { + toast.success("Scan queued. The listing updates when it finishes."); + router.refresh(); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to start the scan."); + }, + }, + ); + const { execute: deletePlugin, isExecuting: isDeleting } = useAction( deletePluginAction, { @@ -124,9 +139,15 @@ export function PluginOwnerMenu({ plugin }: Props) { return null; } - const busy = isRequesting || isToggling || isDeleting; + const busy = isRequesting || isToggling || isRescanning || isDeleting; const canVerify = !plugin.verified && !requestPending; const canPublish = !active && !plugin.permanently_blocked; + // Mirrors the server-side gate in rescanOwnPluginAction. + const canRescan = + !plugin.permanently_blocked && + (plugin.scan_status === "flagged" || + plugin.scan_status === "error" || + plugin.scan_status === "unscanned"); return ( <> @@ -192,6 +213,17 @@ export function PluginOwnerMenu({ plugin }: Props) { ) : null} + {canRescan ? ( + rescanPlugin({ pluginId: plugin.id })} + > + + Re-run scan + + ) : null} +