Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
checks:
name: Lint & typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
# Keep in step with the version developers run locally so
# --frozen-lockfile behaves identically in CI.
bun-version: 1.3.1

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Biome
run: bunx biome ci .

- name: Typecheck
working-directory: apps/cursor
run: bun run typecheck
9 changes: 0 additions & 9 deletions apps/cursor/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,5 @@ GITHUB_TOKEN=
# Vercel Web Analytics is enabled in the Vercel dashboard (Project → Analytics)
# and requires no env vars; the @vercel/analytics client is a no-op in dev.

# 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

# Shared secret guarding /api/cron/* routes against unauthenticated callers.
CRON_SECRET=
9 changes: 9 additions & 0 deletions apps/cursor/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));

/** @type {import('next').NextConfig} */
const nextConfig = {
cacheComponents: true,
turbopack: {
root: resolve(__dirname, "../.."),
},
Expand Down Expand Up @@ -36,6 +37,14 @@ const nextConfig = {
destination: "/",
permanent: true,
},
{
// Legacy MCP detail URLs map to their plugin page. Excludes `new`,
// which is the MCP submission form route. Edit pages (`/mcp/x/edit`)
// are two segments deep and never match this single-segment source.
source: "/mcp/:slug((?!new$)[^/]+)",
destination: "/plugins/mcp-:slug",
permanent: true,
},
{
source: "/official/:path*",
destination: "/",
Expand Down
24 changes: 4 additions & 20 deletions apps/cursor/src/actions/create-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,13 @@
"use server";

import { revalidatePath } from "next/cache";
import { updateTag } from "next/cache";
import { z } from "zod";
import { resolveGithubRepoIdFromRepository } from "@/lib/github-plugin/parse";
import { InsertPluginError, insertPlugin } from "@/lib/plugins/insert";
import { componentInputSchema } from "@/lib/plugins/types";
import { pluginScanLimit } from "@/lib/rate-limit";
import { ActionError, authActionClient } from "./safe-action";

const componentSchema = z.object({
type: z.enum([
"rule",
"mcp_server",
"skill",
"agent",
"hook",
"lsp_server",
"command",
]),
name: z.string().min(1),
slug: z.string().optional(),
description: z.string().optional(),
content: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});

export const createPluginAction = authActionClient
.metadata({
actionName: "create-plugin",
Expand All @@ -39,7 +23,7 @@ export const createPluginAction = authActionClient
homepage: z.string().url().nullable().optional(),
keywords: z.array(z.string()).optional(),
components: z
.array(componentSchema)
.array(componentInputSchema)
.min(1, "At least one component is required"),
}),
)
Expand Down Expand Up @@ -100,7 +84,7 @@ export const createPluginAction = authActionClient
throw err;
}

revalidatePath("/");
updateTag("plugins");

return { slug: result.slug };
},
Expand Down
10 changes: 7 additions & 3 deletions apps/cursor/src/actions/delete-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";

import { revalidatePath } from "next/cache";
import { revalidatePath, updateTag } from "next/cache";
import { z } from "zod";
import { createClient } from "@/utils/supabase/server";
import { ActionError, authActionClient } from "./safe-action";
Expand Down Expand Up @@ -28,7 +28,9 @@ export const deletePluginAction = authActionClient
}

if (existing.owner_id !== userId) {
throw new ActionError("You do not have permission to delete this plugin.");
throw new ActionError(
"You do not have permission to delete this plugin.",
);
}

const { error } = await supabase
Expand All @@ -41,7 +43,9 @@ export const deletePluginAction = authActionClient
throw new ActionError(`Failed to delete plugin: ${error.message}`);
}

revalidatePath("/");
// Deletions must disappear from cached lists immediately for the owner.
updateTag("plugins");
updateTag(`plugin-${existing.slug}`);
revalidatePath("/admin/plugins");

return { slug: existing.slug };
Expand Down
4 changes: 2 additions & 2 deletions apps/cursor/src/actions/request-plugin-verification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";

import { revalidatePath } from "next/cache";
import { revalidatePath, updateTag } from "next/cache";
import { z } from "zod";
import { createClient } from "@/utils/supabase/admin-client";
import { ActionError, authActionClient } from "./safe-action";
Expand Down Expand Up @@ -40,6 +40,6 @@ export const requestPluginVerificationAction = authActionClient
}

revalidatePath("/admin/plugins");
revalidatePath(`/plugins/${plugin.slug}`);
updateTag(`plugin-${plugin.slug}`);
return { success: true };
});
29 changes: 19 additions & 10 deletions apps/cursor/src/actions/review-flagged-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";

import { revalidatePath } from "next/cache";
import { revalidatePath, updateTag } from "next/cache";
import { z } from "zod";
import { enqueuePluginScan, kickDrainAfterResponse } from "@/lib/plugins/queue";
import { createClient } from "@/utils/supabase/admin-client";
Expand Down Expand Up @@ -36,8 +36,8 @@ export const approveFlaggedPluginAction = adminActionClient
}

revalidatePath("/admin/plugins");
revalidatePath("/");
revalidatePath(`/plugins/${plugin.slug}`);
updateTag("plugins");
updateTag(`plugin-${plugin.slug}`);
return { success: true };
});

Expand Down Expand Up @@ -65,8 +65,8 @@ export const confirmFlagAction = adminActionClient
}

revalidatePath("/admin/plugins");
revalidatePath("/");
revalidatePath(`/plugins/${plugin.slug}`);
updateTag("plugins");
updateTag(`plugin-${plugin.slug}`);
Comment thread
leerob marked this conversation as resolved.
return { success: true };
});

Expand All @@ -76,17 +76,27 @@ export const rescanPluginAction = adminActionClient
.action(async ({ parsedInput: { pluginId } }) => {
const supabase = await createClient();

const { error: resetError } = await supabase
const { data: plugin, error: resetError } = await supabase
.from("plugins")
.update({ scan_status: "pending" })
.eq("id", pluginId);
.eq("id", pluginId)
.select("slug")
.single();

if (resetError) {
if (resetError || !plugin) {
throw new ActionError(
`Failed to reset scan state: ${resetError.message}`,
`Failed to reset scan state: ${resetError?.message ?? "not found"}`,
);
}

// The status reset must reach cached readers (detail banner, leaderboard)
// right away, so invalidate before enqueueing — a queue failure must not
// leave cached views showing the stale status when the row is already
// pending. The drain route only invalidates again once the scan ends.
revalidatePath("/admin/plugins");
updateTag("plugins");
updateTag(`plugin-${plugin.slug}`);

try {
await enqueuePluginScan(pluginId);
kickDrainAfterResponse();
Expand All @@ -96,6 +106,5 @@ export const rescanPluginAction = adminActionClient
);
}

revalidatePath("/admin/plugins");
return { success: true };
});
8 changes: 4 additions & 4 deletions apps/cursor/src/actions/review-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";

import { revalidatePath } from "next/cache";
import { revalidatePath, updateTag } from "next/cache";
import { z } from "zod";
import { createClient } from "@/utils/supabase/admin-client";
import { ActionError, adminActionClient } from "./safe-action";
Expand All @@ -27,10 +27,10 @@ export const approvePluginAction = adminActionClient
.single();

revalidatePath("/admin/plugins");
revalidatePath("/");
updateTag("plugins");

if (plugin?.slug) {
revalidatePath(`/plugins/${plugin.slug}`);
updateTag(`plugin-${plugin.slug}`);
}

return { success: true };
Expand All @@ -52,7 +52,7 @@ export const declinePluginAction = adminActionClient
}

revalidatePath("/admin/plugins");
revalidatePath("/");
updateTag("plugins");

return { success: true };
});
15 changes: 10 additions & 5 deletions apps/cursor/src/actions/safe-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,18 @@ export const actionClient = createSafeActionClient({
actionName: z.string(),
});
},
// Define logging middleware.
}).use(async ({ next, clientInput, metadata }) => {
}).use(async ({ next, metadata }) => {
const result = await next();

console.log("Result ->", result);
console.log("Client input ->", clientInput);
console.log("Metadata ->", metadata);
// Log failures only — never inputs or results, which can contain user PII
// (emails, profile fields). Server errors are already logged with their
// message in handleServerError; this adds which action failed.
if (result.serverError || result.validationErrors) {
console.error(`[action:${metadata?.actionName}] failed`, {
serverError: result.serverError,
validationErrors: result.validationErrors,
});
}

return result;
});
Expand Down
11 changes: 7 additions & 4 deletions apps/cursor/src/actions/star-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";

import { revalidatePath } from "next/cache";
import { revalidateTag, updateTag } from "next/cache";
import { z } from "zod";
import { createClient } from "@/utils/supabase/server";
import { ActionError, authActionClient } from "./safe-action";
Expand All @@ -15,7 +15,7 @@ export const starPluginAction = authActionClient
slug: z.string(),
}),
)
.action(async ({ parsedInput: { pluginId, slug } }) => {
.action(async ({ parsedInput: { pluginId, slug }, ctx: { userId } }) => {
// User-scoped client so `auth.uid()` inside the SECURITY DEFINER RPC
// authorizes against the caller, not the service role.
const supabase = await createClient();
Expand All @@ -28,6 +28,9 @@ export const starPluginAction = authActionClient
throw new ActionError(`Failed to update star: ${error.message}`);
}

revalidatePath("/");
revalidatePath(`/plugins/${slug}`);
// Star counts can refresh in the background, but the user's own starred
// list must reflect the change immediately.
revalidateTag("plugins", "max");
revalidateTag(`plugin-${slug}`, "max");
updateTag(`stars-${userId}`);
});
23 changes: 0 additions & 23 deletions apps/cursor/src/actions/subscribe-action.ts

This file was deleted.

19 changes: 16 additions & 3 deletions apps/cursor/src/actions/toggle-follow-action.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";

import { revalidatePath } from "next/cache";
import { revalidateTag, updateTag } from "next/cache";
import { z } from "zod";
import { createClient } from "@/utils/supabase/server";
import { authActionClient } from "./safe-action";
Expand All @@ -23,6 +23,19 @@ export const toggleFollowAction = authActionClient
}) => {
const supabase = await createClient();

const invalidate = () => {
// Profile follower counts, the followed user's followers list, and
// the current user's following list must all reflect the change.
updateTag(`user-${slug}`);
updateTag(`followers-${userId}`);
updateTag(`following-${currentUserId}`);
// The members directory caches rows (incl. follower_count) under
// `users`. Background-refresh it like other ambient counters
// (see star-plugin) instead of synchronously flushing every
// users-tagged entry on each follow click.
revalidateTag("users", "max");
};
Comment thread
leerob marked this conversation as resolved.

if (action === "follow") {
const { error } = await supabase
.from("followers")
Expand All @@ -32,7 +45,7 @@ export const toggleFollowAction = authActionClient
throw new Error(error.message);
}

revalidatePath(`/u/${slug}`);
invalidate();
return;
}

Expand All @@ -44,6 +57,6 @@ export const toggleFollowAction = authActionClient
.eq("following_id", userId);
}

revalidatePath(`/u/${slug}`);
invalidate();
},
);
Loading
Loading