diff --git a/.changeset/chilly-tips-explode.md b/.changeset/chilly-tips-explode.md
new file mode 100644
index 00000000000..7a5235904a4
--- /dev/null
+++ b/.changeset/chilly-tips-explode.md
@@ -0,0 +1,5 @@
+---
+"trigger.dev": patch
+---
+
+Add platform notifications support to the CLI. The `trigger dev` and `trigger login` commands now fetch and display platform notifications (info, warn, error, success) from the server. Includes discovery-based filtering to conditionally show notifications based on project file patterns, color markup rendering for styled terminal output, and a non-blocking display flow with a spinner fallback for slow fetches. Use `--skip-platform-notifications` flag with `trigger dev` to disable the notification check.
diff --git a/.server-changes/platform-notifications.md b/.server-changes/platform-notifications.md
new file mode 100644
index 00000000000..54d52d77673
--- /dev/null
+++ b/.server-changes/platform-notifications.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+Add platform notifications to inform users about new features, changelogs, and platform events directly in the dashboard.
diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
index 1626ec9f910..39a3d386783 100644
--- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
+++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
@@ -11,6 +11,7 @@ import {
import { cn } from "~/utils/cn";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useState } from "react";
+import { useRecentChangelogs } from "~/routes/resources.platform-changelogs";
import { motion } from "framer-motion";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
@@ -38,6 +39,7 @@ export function HelpAndFeedback({
}) {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const currentPlan = useCurrentPlan();
+ const { changelogs } = useRecentChangelogs();
useShortcutKeys({
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
@@ -140,96 +142,7 @@ export function HelpAndFeedback({
data-action="suggest-a-feature"
target="_blank"
/>
-
-
-
-
Need help?
- {currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
-
-
-
- )}
-
-
+
+
What's new
+ {changelogs.map((entry) => (
+
+ ))}
+
+
);
}
+
+function GrayDotIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx
new file mode 100644
index 00000000000..fdfbb2f8742
--- /dev/null
+++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx
@@ -0,0 +1,312 @@
+import { BellAlertIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import { useFetcher } from "@remix-run/react";
+import { motion } from "framer-motion";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import ReactMarkdown from "react-markdown";
+import { Header3 } from "~/components/primitives/Headers";
+import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import { usePlatformNotifications } from "~/routes/resources.platform-notifications";
+import { cn } from "~/utils/cn";
+
+type Notification = {
+ id: string;
+ friendlyId: string;
+ scope: string;
+ priority: number;
+ payload: {
+ version: string;
+ data: {
+ title: string;
+ description: string;
+ image?: string;
+ actionLabel?: string;
+ actionUrl?: string;
+ dismissOnAction?: boolean;
+ };
+ };
+ isRead: boolean;
+};
+
+export function NotificationPanel({
+ isCollapsed,
+ hasIncident,
+ organizationId,
+ projectId,
+}: {
+ isCollapsed: boolean;
+ hasIncident: boolean;
+ organizationId: string;
+ projectId: string;
+}) {
+ const { notifications } = usePlatformNotifications(organizationId, projectId) as {
+ notifications: Notification[];
+ };
+ const [dismissedIds, setDismissedIds] = useState>(new Set());
+ const dismissFetcher = useFetcher();
+ const seenIdsRef = useRef>(new Set());
+ const seenFetcher = useFetcher();
+ const clickedIdsRef = useRef>(new Set());
+ const clickFetcher = useFetcher();
+
+ const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id));
+ const notification = visibleNotifications[0] ?? null;
+
+ const handleDismiss = useCallback((id: string) => {
+ setDismissedIds((prev) => new Set(prev).add(id));
+
+ dismissFetcher.submit(
+ {},
+ {
+ method: "POST",
+ action: `/resources/platform-notifications/${id}/dismiss`,
+ }
+ );
+ }, []);
+
+ const fireClickBeacon = useCallback((id: string) => {
+ if (clickedIdsRef.current.has(id)) return;
+ clickedIdsRef.current.add(id);
+
+ clickFetcher.submit(
+ {},
+ {
+ method: "POST",
+ action: `/resources/platform-notifications/${id}/clicked`,
+ }
+ );
+ }, []);
+
+ // Fire seen beacon
+ const fireSeenBeacon = useCallback((n: Notification) => {
+ if (seenIdsRef.current.has(n.id)) return;
+ seenIdsRef.current.add(n.id);
+
+ seenFetcher.submit(
+ {},
+ {
+ method: "POST",
+ action: `/resources/platform-notifications/${n.id}/seen`,
+ }
+ );
+ }, []);
+
+ // Beacon current notification on mount
+ useEffect(() => {
+ if (notification && !hasIncident) {
+ fireSeenBeacon(notification);
+ }
+ }, [notification?.id, hasIncident]);
+
+ if (!notification) {
+ return null;
+ }
+
+ const card = (
+ fireClickBeacon(notification.id)}
+ />
+ );
+
+ return (
+
+
+ {/* Expanded sidebar: show card directly */}
+
+ {card}
+
+
+ {/* Collapsed sidebar: show bell icon that opens popover */}
+
+
+
+
+
+ {visibleNotifications.length}
+
+
+
+ }
+ content="Notifications"
+ side="right"
+ sideOffset={8}
+ disableHoverableContent
+ asChild
+ />
+
+
+
+ {card}
+
+
+ );
+}
+
+function NotificationCard({
+ notification,
+ onDismiss,
+ onLinkClick,
+}: {
+ notification: Notification;
+ onDismiss: (id: string) => void;
+ onLinkClick: () => void;
+}) {
+ const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data;
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const descriptionRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const el = descriptionRef.current;
+ if (el) {
+ setIsOverflowing(el.scrollHeight > el.clientHeight);
+ }
+ }, [description]);
+
+ const handleDismiss = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onDismiss(notification.id);
+ };
+
+ const handleToggleExpand = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsExpanded((v) => !v);
+ };
+
+ const handleCardClick = () => {
+ onLinkClick();
+ if (dismissOnAction) {
+ onDismiss(notification.id);
+ }
+ };
+
+ const Wrapper = actionUrl ? "a" : "div";
+ const wrapperProps = actionUrl
+ ? {
+ href: actionUrl,
+ target: "_blank" as const,
+ rel: "noopener noreferrer" as const,
+ onClick: handleCardClick,
+ }
+ : {};
+
+ return (
+
+ {/* Header: title + dismiss */}
+
+
+ {title}
+
+
+
+
+ {/* Body: description + chevron */}
+
+
+
+
+ {description}
+
+ {(isOverflowing || isExpanded) && (
+
+ )}
+
+ {actionUrl && (
+
+
+
+ )}
+
+
+ {image && (
+
})
+ )}
+
+
+ );
+}
+
+/** Sanitize image URL to prevent XSS via javascript: or data: URIs. */
+function sanitizeImageUrl(url: string): string {
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol === "https:" || parsed.protocol === "http:") {
+ return parsed.href;
+ }
+ return "";
+ } catch {
+ return "";
+ }
+}
+
+function getMarkdownComponents(onLinkClick: () => void) {
+ return {
+ p: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
+ {
+ e.stopPropagation();
+ onLinkClick();
+ }}
+ >
+ {children}
+
+ ),
+ strong: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ em: ({ children }: { children?: React.ReactNode }) => {children},
+ code: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ };
+}
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index 90f25fde788..d64fc96488c 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -53,6 +53,7 @@ import { type UserWithDashboardPreferences } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents";
+import { NotificationPanel } from "./NotificationPanel";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -701,6 +702,12 @@ export function SideMenu({
hasIncident={incidentStatus.hasIncident}
isManagedCloud={incidentStatus.isManagedCloud}
/>
+
> {
+ const authResult = await authenticateApiRequestWithPersonalAccessToken(request);
+ if (!authResult) {
+ return err({ status: 401, message: "Invalid or Missing API key" });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { id: authResult.userId },
+ select: { id: true, admin: true },
+ });
+
+ if (!user?.admin) {
+ return err({
+ status: user ? 403 : 401,
+ message: user ? "You must be an admin to perform this action" : "Invalid or Missing API key",
+ });
+ }
+
+ return ok(user);
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+ if (request.method !== "POST") {
+ return json({ error: "Method not allowed" }, { status: 405 });
+ }
+
+ const authResult = await authenticateAdmin(request);
+ if (authResult.isErr()) {
+ const { status, message } = authResult.error;
+ return json({ error: message }, { status });
+ }
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+ const result = await createPlatformNotification(body as CreatePlatformNotificationInput);
+
+ if (result.isErr()) {
+ const error = result.error;
+
+ if (error.type === "validation") {
+ return json({ error: "Validation failed", details: error.issues }, { status: 400 });
+ }
+
+ return json({ error: error.message }, { status: 500 });
+ }
+
+ return json(result.value, { status: 201 });
+}
diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx
new file mode 100644
index 00000000000..d9b06816953
--- /dev/null
+++ b/apps/webapp/app/routes/admin.notifications.tsx
@@ -0,0 +1,1078 @@
+import { ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import { useFetcher, useSearchParams } from "@remix-run/react";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { redirect } from "@remix-run/server-runtime";
+import { useRef, useState, useLayoutEffect } from "react";
+import ReactMarkdown from "react-markdown";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { z } from "zod";
+import { Button } from "~/components/primitives/Buttons";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "~/components/primitives/Dialog";
+import { Header3 } from "~/components/primitives/Headers";
+import { Input } from "~/components/primitives/Input";
+import { PaginationControls } from "~/components/primitives/Pagination";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import {
+ Table,
+ TableBlankRow,
+ TableBody,
+ TableCell,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+} from "~/components/primitives/Table";
+import { prisma } from "~/db.server";
+import { requireUserId } from "~/services/session.server";
+import {
+ createPlatformNotification,
+ getAdminNotificationsList,
+} from "~/services/platformNotifications.server";
+import { createSearchParams } from "~/utils/searchParams";
+import { cn } from "~/utils/cn";
+
+const PAGE_SIZE = 20;
+
+const WEBAPP_TYPES = ["card", "changelog"] as const;
+const CLI_TYPES = ["info", "warn", "error", "success"] as const;
+
+const SearchParams = z.object({
+ page: z.coerce.number().optional(),
+ hideArchived: z.coerce.boolean().optional(),
+});
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const user = await prisma.user.findUnique({ where: { id: userId } });
+ if (!user?.admin) throw redirect("/");
+
+ const searchParams = createSearchParams(request.url, SearchParams);
+ if (!searchParams.success) throw new Error(searchParams.error);
+ const { page: rawPage, hideArchived } = searchParams.params.getAll();
+ const page = rawPage ?? 1;
+
+ const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE, hideArchived: hideArchived ?? false });
+
+ return typedjson({ ...data, userId });
+};
+
+export async function action({ request }: ActionFunctionArgs) {
+ const userId = await requireUserId(request);
+ const user = await prisma.user.findUnique({ where: { id: userId } });
+ if (!user?.admin) throw redirect("/");
+
+ const formData = await request.formData();
+ const _action = formData.get("_action");
+
+ if (_action === "create" || _action === "create-preview") {
+ return handleCreateAction(formData, userId, _action === "create-preview");
+ }
+
+ if (_action === "archive") {
+ return handleArchiveAction(formData);
+ }
+
+ return typedjson({ error: "Unknown action" }, { status: 400 });
+}
+
+async function handleCreateAction(formData: FormData, userId: string, isPreview: boolean) {
+ const surface = formData.get("surface") as string;
+ const payloadType = formData.get("payloadType") as string;
+ const adminLabel = formData.get("adminLabel") as string;
+ const title = formData.get("title") as string;
+ const description = formData.get("description") as string;
+ const actionUrl = (formData.get("actionUrl") as string) || undefined;
+ const image = (formData.get("image") as string) || undefined;
+ const dismissOnAction = formData.get("dismissOnAction") === "true";
+ const startsAt = formData.get("startsAt") as string;
+ const endsAt = formData.get("endsAt") as string;
+ const priority = Number(formData.get("priority") || "0");
+
+ if (!adminLabel || !title || !description || !endsAt || !surface || !payloadType) {
+ return typedjson({ error: "Missing required fields" }, { status: 400 });
+ }
+
+ const cliMaxShowCount = formData.get("cliMaxShowCount")
+ ? Number(formData.get("cliMaxShowCount"))
+ : undefined;
+ const cliMaxDaysAfterFirstSeen = formData.get("cliMaxDaysAfterFirstSeen")
+ ? Number(formData.get("cliMaxDaysAfterFirstSeen"))
+ : undefined;
+ const cliShowEvery = formData.get("cliShowEvery")
+ ? Number(formData.get("cliShowEvery"))
+ : undefined;
+
+ const discoveryFilePatterns = (formData.get("discoveryFilePatterns") as string) || "";
+ const discoveryContentPattern =
+ (formData.get("discoveryContentPattern") as string) || undefined;
+ const discoveryMatchBehavior = (formData.get("discoveryMatchBehavior") as string) || "";
+
+ const discovery =
+ discoveryFilePatterns && discoveryMatchBehavior
+ ? {
+ filePatterns: discoveryFilePatterns
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ ...(discoveryContentPattern ? { contentPattern: discoveryContentPattern } : {}),
+ matchBehavior: discoveryMatchBehavior as "show-if-found" | "show-if-not-found",
+ }
+ : undefined;
+
+ const result = await createPlatformNotification({
+ title: isPreview ? `[Preview] ${adminLabel}` : adminLabel,
+ payload: {
+ version: "1" as const,
+ data: {
+ type: payloadType as "info" | "warn" | "error" | "success" | "card" | "changelog",
+ title,
+ description,
+ ...(actionUrl ? { actionUrl } : {}),
+ ...(image ? { image } : {}),
+ ...(dismissOnAction ? { dismissOnAction: true } : {}),
+ ...(discovery ? { discovery } : {}),
+ },
+ },
+ surface: surface as "CLI" | "WEBAPP",
+ scope: isPreview ? "USER" : "GLOBAL",
+ ...(isPreview ? { userId } : {}),
+ startsAt: isPreview
+ ? new Date().toISOString()
+ : startsAt
+ ? new Date(startsAt + "Z").toISOString()
+ : new Date().toISOString(),
+ endsAt: isPreview
+ ? new Date(Date.now() + 60 * 60 * 1000).toISOString()
+ : new Date(endsAt + "Z").toISOString(),
+ priority,
+ ...(surface === "CLI"
+ ? isPreview
+ ? { cliMaxShowCount: 1 }
+ : {
+ cliMaxShowCount,
+ cliMaxDaysAfterFirstSeen,
+ cliShowEvery,
+ }
+ : {}),
+ });
+
+ if (result.isErr()) {
+ const err = result.error;
+ if (err.type === "validation") {
+ return typedjson(
+ { error: err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") },
+ { status: 400 }
+ );
+ }
+ return typedjson({ error: err.message }, { status: 500 });
+ }
+
+ if (isPreview) {
+ return typedjson({ success: true, previewId: result.value.id });
+ }
+ return typedjson({ success: true, id: result.value.id });
+}
+
+async function handleArchiveAction(formData: FormData) {
+ const notificationId = formData.get("notificationId") as string;
+ if (!notificationId) {
+ return typedjson({ error: "Missing notificationId" }, { status: 400 });
+ }
+
+ await prisma.platformNotification.update({
+ where: { id: notificationId },
+ data: { archivedAt: new Date() },
+ });
+
+ return typedjson({ success: true });
+}
+
+export default function AdminNotificationsRoute() {
+ const { notifications, total, page, pageCount } = useTypedLoaderData();
+ const [showCreate, setShowCreate] = useState(false);
+ const createFetcher = useFetcher<{
+ success?: boolean;
+ error?: string;
+ id?: string;
+ previewId?: string;
+ }>();
+ const archiveFetcher = useFetcher<{ success?: boolean; error?: string }>();
+ const [surface, setSurface] = useState<"CLI" | "WEBAPP">("WEBAPP");
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [actionUrl, setActionUrl] = useState("");
+ const [image, setImage] = useState("");
+ const [payloadType, setPayloadType] = useState("card");
+ const [detailNotification, setDetailNotification] = useState<(typeof notifications)[number] | null>(null);
+
+ const typeOptions = surface === "WEBAPP" ? WEBAPP_TYPES : CLI_TYPES;
+
+ // Reset type when surface changes if current type isn't valid for new surface
+ const handleSurfaceChange = (newSurface: "CLI" | "WEBAPP") => {
+ setSurface(newSurface);
+ const newTypes = newSurface === "WEBAPP" ? WEBAPP_TYPES : CLI_TYPES;
+ if (!newTypes.includes(payloadType as any)) {
+ setPayloadType(newTypes[0]);
+ }
+ };
+
+ const [urlSearchParams, setUrlSearchParams] = useSearchParams();
+ const hideArchived = urlSearchParams.get("hideArchived") === "true";
+
+ const toggleHideArchived = () => {
+ setUrlSearchParams((prev) => {
+ const next = new URLSearchParams(prev);
+ if (hideArchived) {
+ next.delete("hideArchived");
+ } else {
+ next.set("hideArchived", "true");
+ }
+ next.delete("page");
+ return next;
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ {showCreate && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* CLI live preview */}
+ {surface === "CLI" && (title || description) && (
+
+
+ CLI Preview
+
+
+ {title && (
+
+
+
+ )}
+ {description && (
+
+
+
+ )}
+ {actionUrl && (
+
{actionUrl}
+ )}
+
+
+ )}
+
+
+
+ setTitle(e.target.value)}
+ />
+
+
+ {/* Description + live preview (webapp only) */}
+
+
+ {surface === "WEBAPP" ? (
+
+ ) : (
+
+
+ {/* Action URL + dismiss on action (webapp gets both in one row) */}
+ {surface === "WEBAPP" ? (
+
+ ) : (
+
+
+ setActionUrl(e.target.value)}
+ />
+
+ )}
+
+ {surface === "WEBAPP" && (
+
+
+ setImage(e.target.value)}
+ />
+
+ )}
+
+
+
+ {surface === "CLI" && (
+ <>
+
+
+
+
+ Discovery (optional) — only show notification if file pattern matches
+
+
+
+
+
+ Comma-separated
+
+
+
+
+ Regex (optional)
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ {createFetcher.data?.error && (
+ {createFetcher.data.error}
+ )}
+ {createFetcher.data?.success && !createFetcher.data.previewId && (
+ Created successfully
+ )}
+ {createFetcher.data?.previewId && (
+
+ Preview sent (ID: {createFetcher.data.previewId})
+
+ )}
+
+
+
+ )}
+
+
+
+ {total} notifications (page {page} of {pageCount || 1})
+
+
+
+
+
+
+
+ Title
+ Surface
+ Scope
+ Type
+ Starts (UTC)
+ Ends (UTC)
+ Seen
+ Clicked
+ Dismissed
+ Actions
+ Status
+
+
+
+ {notifications.length === 0 ? (
+
+ No notifications found
+
+ ) : (
+ notifications.map((n) => {
+ const status = getNotificationStatus(n);
+ const isActive = status === "active";
+ return (
+
+
+
+
+
+ {n.surface}
+
+
+ {n.scope}
+
+
+ {n.payloadType ?? "—"}
+
+
+ {formatDate(n.startsAt)}
+
+
+ {formatDate(n.endsAt)}
+
+
+ {n.stats.seen}
+
+
+ {n.stats.clicked}
+
+
+ {n.stats.dismissed}
+
+
+ {isActive && (
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+function NotificationDetailContent({
+ notification: n,
+}: {
+ notification: {
+ id: string;
+ friendlyId: string;
+ surface: string;
+ scope: string;
+ priority: number;
+ startsAt: Date;
+ endsAt: Date;
+ archivedAt: Date | null;
+ createdAt: Date;
+ payloadTitle: string | null;
+ payloadType: string | null;
+ payloadDescription: string | null;
+ payloadActionUrl: string | null | undefined;
+ payloadImage: string | null | undefined;
+ cliMaxShowCount: number | null;
+ cliMaxDaysAfterFirstSeen: number | null;
+ cliShowEvery: number | null;
+ stats: { seen: number; clicked: number; dismissed: number };
+ };
+}) {
+ return (
+
+ {/* Preview */}
+ {n.payloadTitle && n.payloadDescription && (
+
+
Preview
+ {n.surface === "WEBAPP" ? (
+
+ ) : (
+
+
+
+
+
+
+
+ {n.payloadActionUrl && (
+
{n.payloadActionUrl}
+ )}
+
+ )}
+
+ )}
+
+ {/* Details grid */}
+
+
+
+
+
+
+
+
+
+ {n.archivedAt && }
+ {n.payloadActionUrl && }
+
+
+ {/* CLI settings */}
+ {n.surface === "CLI" && (n.cliMaxShowCount || n.cliMaxDaysAfterFirstSeen || n.cliShowEvery) && (
+
+
CLI Settings
+
+ {n.cliMaxShowCount != null && (
+
+ )}
+ {n.cliMaxDaysAfterFirstSeen != null && (
+
+ )}
+ {n.cliShowEvery != null && (
+
+ )}
+
+
+ )}
+
+ {/* Stats */}
+
+
+ );
+}
+
+function DetailRow({ label, value }: { label: string; value: string }) {
+ return (
+ <>
+ {label}
+ {value}
+ >
+ );
+}
+
+function StatCard({ label, value }: { label: string; value: number }) {
+ return (
+
+ );
+}
+
+// Mirrors NotificationCard from NotificationPanel.tsx — static preview, no interactions
+function NotificationPreviewCard({
+ title,
+ description,
+ actionUrl,
+ image,
+}: {
+ title: string;
+ description: string;
+ actionUrl?: string;
+ image?: string;
+}) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const descriptionRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const el = descriptionRef.current;
+ if (el) {
+ setIsOverflowing(el.scrollHeight > el.clientHeight);
+ }
+ }, [description]);
+
+ const Wrapper = actionUrl ? "a" : "div";
+ const wrapperProps = actionUrl
+ ? { href: actionUrl, target: "_blank" as const, rel: "noopener noreferrer" as const }
+ : {};
+
+ return (
+
+
+ {title}
+
+
+
+
+
+
+
+ {description}
+
+ {(isOverflowing || isExpanded) && (
+
+ )}
+
+ {actionUrl && (
+
+
+
+ )}
+
+
+ {image && (
+
})
+ )}
+
+
+ );
+}
+
+const markdownComponents = {
+ p: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
+ e.stopPropagation()}
+ >
+ {children}
+
+ ),
+ strong: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ em: ({ children }: { children?: React.ReactNode }) => {children},
+ code: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+};
+
+const CLI_COLOR_MAP: Record = {
+ red: "text-red-500",
+ green: "text-green-500",
+ yellow: "text-yellow-500",
+ blue: "text-blue-500",
+ magenta: "text-fuchsia-500",
+ cyan: "text-cyan-500",
+ white: "text-white",
+ gray: "text-gray-400",
+ redBright: "text-red-400",
+ greenBright: "text-green-400",
+ yellowBright: "text-yellow-400",
+ blueBright: "text-blue-400",
+ magentaBright: "text-fuchsia-400",
+ cyanBright: "text-cyan-400",
+ whiteBright: "text-white",
+ bold: "font-bold",
+};
+
+function CliColorMarkup({ text, fallbackClass }: { text: string; fallbackClass?: string }) {
+ const parts: React.ReactNode[] = [];
+ let pos = 0;
+ let key = 0;
+
+ while (pos < text.length) {
+ const braceIdx = text.indexOf("{", pos);
+ if (braceIdx === -1) {
+ parts.push(text.slice(pos));
+ break;
+ }
+
+ const closeIdx = text.indexOf("}", braceIdx);
+ if (closeIdx === -1) {
+ parts.push(text.slice(pos));
+ break;
+ }
+
+ const tagName = text.slice(braceIdx + 1, closeIdx);
+ if (CLI_COLOR_MAP[tagName]) {
+ // Found opening tag — look for matching close tag
+ const closeTag = `{/${tagName}}`;
+ const endIdx = text.indexOf(closeTag, closeIdx + 1);
+ if (endIdx !== -1) {
+ // Push text before the tag
+ if (braceIdx > pos) {
+ parts.push({text.slice(pos, braceIdx)});
+ }
+ // Push styled content
+ parts.push(
+
+ {text.slice(closeIdx + 1, endIdx)}
+
+ );
+ pos = endIdx + closeTag.length;
+ continue;
+ }
+ }
+
+ // Not a recognized tag — treat as literal
+ parts.push(text.slice(pos, closeIdx + 1));
+ pos = closeIdx + 1;
+ }
+
+ return <>{parts}>;
+}
+
+function Badge({
+ children,
+ color,
+}: {
+ children: React.ReactNode;
+ color: "amber" | "blue" | "green" | "gray";
+}) {
+ const colors = {
+ amber: "bg-amber-500/20 text-amber-400",
+ blue: "bg-blue-500/20 text-blue-400",
+ green: "bg-green-500/20 text-green-400",
+ gray: "bg-charcoal-700 text-text-dimmed",
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+function formatDate(date: string | Date): string {
+ const d = typeof date === "string" ? new Date(date) : date;
+ return d.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ timeZone: "UTC",
+ });
+}
+
+function toDatetimeLocalUTC(date: Date): string {
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
+}
+
+function defaultStartsAt(): string {
+ return toDatetimeLocalUTC(new Date(Date.now() + 24 * 60 * 60 * 1000));
+}
+
+function defaultEndsAt(): string {
+ return toDatetimeLocalUTC(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000));
+}
+
+/** Sanitize image URL to prevent XSS via javascript: or data: URIs. */
+function sanitizeImageUrl(url: string): string {
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol === "https:" || parsed.protocol === "http:") {
+ return parsed.href;
+ }
+ return "";
+ } catch {
+ return "";
+ }
+}
+
+type NotificationStatus = "active" | "pending" | "expired" | "archived";
+
+function getNotificationStatus(n: {
+ archivedAt: string | Date | null;
+ startsAt: string | Date;
+ endsAt: string | Date;
+}): NotificationStatus {
+ if (n.archivedAt) return "archived";
+ const now = new Date();
+ const starts = typeof n.startsAt === "string" ? new Date(n.startsAt) : n.startsAt;
+ const ends = typeof n.endsAt === "string" ? new Date(n.endsAt) : n.endsAt;
+ if (now < starts) return "pending";
+ if (now >= ends) return "expired";
+ return "active";
+}
+
+function StatusBadge({ status }: { status: NotificationStatus }) {
+ const styles: Record = {
+ active: "bg-green-500/20 text-green-400",
+ pending: "bg-blue-500/20 text-blue-400",
+ expired: "bg-charcoal-700 text-text-dimmed",
+ archived: "bg-red-500/20 text-red-400",
+ };
+
+ return (
+
+ {status}
+
+ );
+}
diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx
index 34792e66ee5..c03de5fc537 100644
--- a/apps/webapp/app/routes/admin.tsx
+++ b/apps/webapp/app/routes/admin.tsx
@@ -36,6 +36,10 @@ export default function Page() {
label: "LLM Models",
to: "/admin/llm-models",
},
+ {
+ label: "Notifications",
+ to: "/admin/notifications",
+ },
]}
layoutId={"admin"}
/>
diff --git a/apps/webapp/app/routes/api.v1.platform-notifications.ts b/apps/webapp/app/routes/api.v1.platform-notifications.ts
new file mode 100644
index 00000000000..7299c407383
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.platform-notifications.ts
@@ -0,0 +1,22 @@
+import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { json } from "@remix-run/server-runtime";
+import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
+import { getNextCliNotification } from "~/services/platformNotifications.server";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
+
+ if (!authenticationResult) {
+ return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
+ }
+
+ const url = new URL(request.url);
+ const projectRef = url.searchParams.get("projectRef") ?? undefined;
+
+ const notification = await getNextCliNotification({
+ userId: authenticationResult.userId,
+ projectRef,
+ });
+
+ return json({ notification });
+}
diff --git a/apps/webapp/app/routes/resources.platform-changelogs.tsx b/apps/webapp/app/routes/resources.platform-changelogs.tsx
new file mode 100644
index 00000000000..da71df2e279
--- /dev/null
+++ b/apps/webapp/app/routes/resources.platform-changelogs.tsx
@@ -0,0 +1,49 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
+import { useEffect, useRef } from "react";
+import { requireUserId } from "~/services/session.server";
+import { getRecentChangelogs } from "~/services/platformNotifications.server";
+
+export const shouldRevalidate: ShouldRevalidateFunction = () => false;
+
+export type PlatformChangelogsLoaderData = {
+ changelogs: Array<{ id: string; title: string; actionUrl?: string }>;
+};
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ await requireUserId(request);
+
+ const changelogs = await getRecentChangelogs();
+
+ return json({ changelogs });
+}
+
+const POLL_INTERVAL_MS = 60_000;
+
+export function useRecentChangelogs() {
+ const fetcher = useFetcher();
+ const hasInitiallyFetched = useRef(false);
+
+ useEffect(() => {
+ const url = "/resources/platform-changelogs";
+
+ if (!hasInitiallyFetched.current && fetcher.state === "idle") {
+ hasInitiallyFetched.current = true;
+ fetcher.load(url);
+ }
+
+ const interval = setInterval(() => {
+ if (fetcher.state === "idle") {
+ fetcher.load(url);
+ }
+ }, POLL_INTERVAL_MS);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return {
+ changelogs: fetcher.data?.changelogs ?? [],
+ isLoading: fetcher.state !== "idle",
+ };
+}
diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.clicked.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.clicked.tsx
new file mode 100644
index 00000000000..8ddf9adbc64
--- /dev/null
+++ b/apps/webapp/app/routes/resources.platform-notifications.$id.clicked.tsx
@@ -0,0 +1,17 @@
+import { json } from "@remix-run/node";
+import type { ActionFunctionArgs } from "@remix-run/node";
+import { requireUserId } from "~/services/session.server";
+import { recordNotificationClicked } from "~/services/platformNotifications.server";
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ const userId = await requireUserId(request);
+ const notificationId = params.id;
+
+ if (!notificationId) {
+ return json({ success: false }, { status: 400 });
+ }
+
+ await recordNotificationClicked({ notificationId, userId });
+
+ return json({ success: true });
+}
diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx
new file mode 100644
index 00000000000..e6a475692d9
--- /dev/null
+++ b/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx
@@ -0,0 +1,17 @@
+import { json } from "@remix-run/node";
+import type { ActionFunctionArgs } from "@remix-run/node";
+import { requireUserId } from "~/services/session.server";
+import { dismissNotification } from "~/services/platformNotifications.server";
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ const userId = await requireUserId(request);
+ const notificationId = params.id;
+
+ if (!notificationId) {
+ return json({ success: false }, { status: 400 });
+ }
+
+ await dismissNotification({ notificationId, userId });
+
+ return json({ success: true });
+}
diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx
new file mode 100644
index 00000000000..652d4ee99c0
--- /dev/null
+++ b/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx
@@ -0,0 +1,17 @@
+import { json } from "@remix-run/node";
+import type { ActionFunctionArgs } from "@remix-run/node";
+import { requireUserId } from "~/services/session.server";
+import { recordNotificationSeen } from "~/services/platformNotifications.server";
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ const userId = await requireUserId(request);
+ const notificationId = params.id;
+
+ if (!notificationId) {
+ return json({ success: false }, { status: 400 });
+ }
+
+ await recordNotificationSeen({ notificationId, userId });
+
+ return json({ success: true });
+}
diff --git a/apps/webapp/app/routes/resources.platform-notifications.tsx b/apps/webapp/app/routes/resources.platform-notifications.tsx
new file mode 100644
index 00000000000..18d54ec352b
--- /dev/null
+++ b/apps/webapp/app/routes/resources.platform-notifications.tsx
@@ -0,0 +1,61 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
+import { useEffect, useRef } from "react";
+import { requireUserId } from "~/services/session.server";
+import {
+ getActivePlatformNotifications,
+ type PlatformNotificationWithPayload,
+} from "~/services/platformNotifications.server";
+
+export const shouldRevalidate: ShouldRevalidateFunction = () => false;
+
+export type PlatformNotificationsLoaderData = {
+ notifications: PlatformNotificationWithPayload[];
+ unreadCount: number;
+};
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const userId = await requireUserId(request);
+ const url = new URL(request.url);
+ const organizationId = url.searchParams.get("organizationId");
+ const projectId = url.searchParams.get("projectId") ?? undefined;
+
+ if (!organizationId) {
+ return json({ notifications: [], unreadCount: 0 });
+ }
+
+ const result = await getActivePlatformNotifications({ userId, organizationId, projectId });
+
+ return json(result);
+}
+
+const POLL_INTERVAL_MS = 60000; // 1 minute
+
+export function usePlatformNotifications(organizationId: string, projectId: string) {
+ const fetcher = useFetcher();
+ const lastLoadedUrl = useRef(null);
+
+ useEffect(() => {
+ const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`;
+
+ if (lastLoadedUrl.current !== url && fetcher.state === "idle") {
+ lastLoadedUrl.current = url;
+ fetcher.load(url);
+ }
+
+ const interval = setInterval(() => {
+ if (fetcher.state === "idle") {
+ fetcher.load(url);
+ }
+ }, POLL_INTERVAL_MS);
+
+ return () => clearInterval(interval);
+ }, [organizationId, projectId]);
+
+ return {
+ notifications: fetcher.data?.notifications ?? [],
+ unreadCount: fetcher.data?.unreadCount ?? 0,
+ isLoading: fetcher.state !== "idle",
+ };
+}
diff --git a/apps/webapp/app/services/platformNotificationCounter.server.ts b/apps/webapp/app/services/platformNotificationCounter.server.ts
new file mode 100644
index 00000000000..dc2970045da
--- /dev/null
+++ b/apps/webapp/app/services/platformNotificationCounter.server.ts
@@ -0,0 +1,45 @@
+import { Redis } from "ioredis";
+import { env } from "~/env.server";
+import { singleton } from "~/utils/singleton";
+import { logger } from "./logger.server";
+
+const KEY_PREFIX = "cli-notif-ctr:";
+const MAX_COUNTER = 1000;
+
+function initializeRedis(): Redis | undefined {
+ const host = env.CACHE_REDIS_HOST;
+ if (!host) return undefined;
+
+ return new Redis({
+ connectionName: "platformNotificationCounter",
+ host,
+ port: env.CACHE_REDIS_PORT,
+ username: env.CACHE_REDIS_USERNAME,
+ password: env.CACHE_REDIS_PASSWORD,
+ keyPrefix: "tr:",
+ enableAutoPipelining: true,
+ ...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
+ });
+}
+
+const redis = singleton("platformNotificationCounter", initializeRedis);
+
+/** Increment and return the user's CLI request counter (0-based, wraps at 1000→0). */
+export async function incrementCliRequestCounter(userId: string): Promise {
+ if (!redis) return 0;
+
+ try {
+ const key = `${KEY_PREFIX}${userId}`;
+ const value = await redis.incr(key);
+
+ if (value > MAX_COUNTER) {
+ await redis.set(key, "0");
+ return 0;
+ }
+
+ return value;
+ } catch (error) {
+ logger.error("Failed to increment CLI notification counter", { userId, error });
+ return 0;
+ }
+}
diff --git a/apps/webapp/app/services/platformNotifications.server.ts b/apps/webapp/app/services/platformNotifications.server.ts
new file mode 100644
index 00000000000..ff91f48708f
--- /dev/null
+++ b/apps/webapp/app/services/platformNotifications.server.ts
@@ -0,0 +1,661 @@
+import { z } from "zod";
+import { errAsync, fromPromise, type ResultAsync } from "neverthrow";
+import { prisma } from "~/db.server";
+import { type PlatformNotificationScope, type PlatformNotificationSurface } from "@trigger.dev/database";
+import { incrementCliRequestCounter } from "./platformNotificationCounter.server";
+
+// --- Payload schema (spec v1) ---
+
+const DiscoverySchema = z.object({
+ filePatterns: z.array(z.string().min(1)).min(1),
+ contentPattern: z
+ .string()
+ .max(200)
+ .optional()
+ .refine(
+ (val) => {
+ if (!val) return true;
+ try {
+ new RegExp(val);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ { message: "contentPattern must be a valid regular expression" }
+ ),
+ matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
+});
+
+const CardDataV1Schema = z.object({
+ type: z.enum(["card", "info", "warn", "error", "success", "changelog"]),
+ title: z.string(),
+ description: z.string(),
+ image: z.string().url().optional(),
+ actionLabel: z.string().optional(),
+ actionUrl: z.string().url().optional(),
+ dismissOnAction: z.boolean().optional(),
+ discovery: DiscoverySchema.optional(),
+});
+
+const PayloadV1Schema = z.object({
+ version: z.literal("1"),
+ data: CardDataV1Schema,
+});
+
+export type PayloadV1 = z.infer;
+
+export type PlatformNotificationWithPayload = {
+ id: string;
+ friendlyId: string;
+ scope: string;
+ priority: number;
+ payload: PayloadV1;
+ isRead: boolean;
+};
+
+// --- Read: admin list with interaction stats ---
+
+export async function getAdminNotificationsList({
+ page = 1,
+ pageSize = 20,
+ hideArchived = false,
+}: {
+ page?: number;
+ pageSize?: number;
+ hideArchived?: boolean;
+}) {
+ const where = hideArchived ? { archivedAt: null } : {};
+
+ const [notifications, total] = await Promise.all([
+ prisma.platformNotification.findMany({
+ where,
+ orderBy: [{ createdAt: "desc" }],
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ include: {
+ _count: {
+ select: { interactions: true },
+ },
+ interactions: {
+ select: {
+ webappDismissedAt: true,
+ webappClickedAt: true,
+ cliDismissedAt: true,
+ },
+ },
+ },
+ }),
+ prisma.platformNotification.count({ where }),
+ ]);
+
+ return {
+ notifications: notifications.map((n) => {
+ const parsed = PayloadV1Schema.safeParse(n.payload);
+ return {
+ id: n.id,
+ friendlyId: n.friendlyId,
+ title: n.title,
+ surface: n.surface,
+ scope: n.scope,
+ priority: n.priority,
+ startsAt: n.startsAt,
+ endsAt: n.endsAt,
+ archivedAt: n.archivedAt,
+ createdAt: n.createdAt,
+ payload: n.payload,
+ payloadTitle: parsed.success ? parsed.data.data.title : null,
+ payloadType: parsed.success ? parsed.data.data.type : null,
+ payloadDescription: parsed.success ? parsed.data.data.description : null,
+ payloadActionUrl: parsed.success ? parsed.data.data.actionUrl : null,
+ payloadImage: parsed.success ? parsed.data.data.image : null,
+ cliMaxShowCount: n.cliMaxShowCount,
+ cliMaxDaysAfterFirstSeen: n.cliMaxDaysAfterFirstSeen,
+ cliShowEvery: n.cliShowEvery,
+ stats: {
+ seen: n._count.interactions,
+ clicked: n.interactions.filter((i) => i.webappClickedAt !== null).length,
+ dismissed: n.interactions.filter(
+ (i) => i.webappDismissedAt !== null || i.cliDismissedAt !== null
+ ).length,
+ },
+ };
+ }),
+ total,
+ page,
+ pageCount: Math.ceil(total / pageSize),
+ };
+}
+
+// --- Read: active notifications for webapp ---
+
+export async function getActivePlatformNotifications({
+ userId,
+ organizationId,
+ projectId,
+}: {
+ userId: string;
+ organizationId: string;
+ projectId?: string;
+}) {
+ const now = new Date();
+
+ const notifications = await prisma.platformNotification.findMany({
+ where: {
+ surface: "WEBAPP",
+ archivedAt: null,
+ startsAt: { lte: now },
+ endsAt: { gt: now },
+ AND: [
+ {
+ OR: [
+ { scope: "GLOBAL" },
+ { scope: "ORGANIZATION", organizationId },
+ ...(projectId ? [{ scope: "PROJECT" as const, projectId }] : []),
+ { scope: "USER", userId },
+ ],
+ },
+ ],
+ },
+ include: {
+ interactions: {
+ where: { userId },
+ },
+ },
+ orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
+ });
+
+ type InternalNotification = PlatformNotificationWithPayload & { createdAt: Date };
+ const result: InternalNotification[] = [];
+
+ for (const n of notifications) {
+ const interaction = n.interactions[0] ?? null;
+
+ if (interaction?.webappDismissedAt) continue;
+
+ const parsed = PayloadV1Schema.safeParse(n.payload);
+ if (!parsed.success) continue;
+
+ result.push({
+ id: n.id,
+ friendlyId: n.friendlyId,
+ scope: n.scope,
+ priority: n.priority,
+ createdAt: n.createdAt,
+ payload: parsed.data,
+ isRead: !!interaction,
+ });
+ }
+
+ result.sort(compareNotifications);
+
+ const unreadCount = result.filter((n) => !n.isRead).length;
+ const notifications_out: PlatformNotificationWithPayload[] = result.map(
+ ({ createdAt: _, ...rest }) => rest
+ );
+
+ return { notifications: notifications_out, unreadCount };
+}
+
+function compareNotifications(
+ a: { priority: number; createdAt: Date },
+ b: { priority: number; createdAt: Date }
+) {
+ const priorityDiff = b.priority - a.priority;
+ if (priorityDiff !== 0) return priorityDiff;
+
+ return b.createdAt.getTime() - a.createdAt.getTime();
+}
+
+// --- Write: upsert interaction ---
+
+async function upsertInteraction({
+ notificationId,
+ userId,
+ onUpdate,
+ onCreate,
+}: {
+ notificationId: string;
+ userId: string;
+ onUpdate: Record;
+ onCreate: Record;
+}) {
+ await prisma.platformNotificationInteraction.upsert({
+ where: { notificationId_userId: { notificationId, userId } },
+ update: onUpdate,
+ create: {
+ notificationId,
+ userId,
+ firstSeenAt: new Date(),
+ showCount: 1,
+ ...onCreate,
+ },
+ });
+}
+
+export async function recordNotificationSeen({
+ notificationId,
+ userId,
+}: {
+ notificationId: string;
+ userId: string;
+}) {
+ return upsertInteraction({
+ notificationId,
+ userId,
+ onUpdate: { showCount: { increment: 1 } },
+ onCreate: {},
+ });
+}
+
+export async function dismissNotification({
+ notificationId,
+ userId,
+}: {
+ notificationId: string;
+ userId: string;
+}) {
+ const now = new Date();
+ return upsertInteraction({
+ notificationId,
+ userId,
+ onUpdate: { webappDismissedAt: now },
+ onCreate: { webappDismissedAt: now },
+ });
+}
+
+export async function recordNotificationClicked({
+ notificationId,
+ userId,
+}: {
+ notificationId: string;
+ userId: string;
+}) {
+ const now = new Date();
+ return upsertInteraction({
+ notificationId,
+ userId,
+ onUpdate: { webappClickedAt: now },
+ onCreate: { webappClickedAt: now },
+ });
+}
+
+// --- Read: recent changelogs (for Help & Feedback) ---
+
+export async function getRecentChangelogs({ limit = 2 }: { limit?: number } = {}) {
+ // NOTE: Intentionally not filtering by archivedAt, startsAt, or endsAt.
+ // We want to show archived and expired changelogs in the "What's new" section
+ // so users can still find recent release notes.
+ const notifications = await prisma.platformNotification.findMany({
+ where: {
+ surface: "WEBAPP",
+ payload: { path: ["data", "type"], equals: "changelog" },
+ },
+ orderBy: [{ createdAt: "desc" }],
+ take: limit,
+ });
+
+ return notifications
+ .map((n) => {
+ const parsed = PayloadV1Schema.safeParse(n.payload);
+ if (!parsed.success) return null;
+ return { id: n.id, title: parsed.data.data.title, actionUrl: parsed.data.data.actionUrl };
+ })
+ .filter(Boolean) as Array<{ id: string; title: string; actionUrl?: string }>;
+}
+
+// --- CLI: next notification for CLI surface ---
+
+function isCliNotificationExpired(
+ interaction: {
+ userId: string;
+ firstSeenAt: Date;
+ showCount: number;
+ cliDismissedAt: Date | null;
+ } | null,
+ notification: {
+ id: string;
+ cliMaxDaysAfterFirstSeen: number | null;
+ cliMaxShowCount: number | null;
+ }
+): boolean {
+ if (!interaction) return false;
+
+ let expired = false;
+
+ if (
+ notification.cliMaxShowCount !== null &&
+ interaction.showCount >= notification.cliMaxShowCount
+ ) {
+ expired = true;
+ }
+
+ if (!expired && notification.cliMaxDaysAfterFirstSeen !== null) {
+ const daysSinceFirstSeen =
+ (Date.now() - interaction.firstSeenAt.getTime()) / (1000 * 60 * 60 * 24);
+ if (daysSinceFirstSeen > notification.cliMaxDaysAfterFirstSeen) {
+ expired = true;
+ }
+ }
+
+ // For time-based expiration, persist the dismiss on the next request
+ // (showCount-based dismissal is handled inline at display time)
+ if (expired && !interaction.cliDismissedAt) {
+ void prisma.platformNotificationInteraction.update({
+ where: {
+ notificationId_userId: {
+ notificationId: notification.id,
+ userId: interaction.userId,
+ },
+ },
+ data: { cliDismissedAt: new Date() },
+ });
+ }
+
+ return expired;
+}
+
+export async function getNextCliNotification({
+ userId,
+ projectRef,
+}: {
+ userId: string;
+ projectRef?: string;
+}): Promise<{
+ id: string;
+ payload: PayloadV1;
+ showCount: number;
+ firstSeenAt: string;
+} | null> {
+ const now = new Date();
+
+ // Resolve organizationId and projectId from projectRef if provided
+ let organizationId: string | undefined;
+ let projectId: string | undefined;
+
+ if (projectRef) {
+ const project = await prisma.project.findFirst({
+ where: {
+ externalRef: projectRef,
+ deletedAt: null,
+ organization: {
+ deletedAt: null,
+ members: { some: { userId } },
+ },
+ },
+ select: { id: true, organizationId: true },
+ });
+
+ if (project) {
+ projectId = project.id;
+ organizationId = project.organizationId;
+ }
+ }
+
+ // If no projectRef or project not found, get org from membership
+ if (!organizationId) {
+ const membership = await prisma.orgMember.findFirst({
+ where: { userId },
+ select: { organizationId: true },
+ });
+ if (membership) {
+ organizationId = membership.organizationId;
+ }
+ }
+
+ const scopeFilter: Array> = [
+ { scope: "GLOBAL" },
+ { scope: "USER", userId },
+ ];
+
+ if (organizationId) {
+ scopeFilter.push({ scope: "ORGANIZATION", organizationId });
+ }
+
+ if (projectId) {
+ scopeFilter.push({ scope: "PROJECT", projectId });
+ }
+
+ const notifications = await prisma.platformNotification.findMany({
+ where: {
+ surface: "CLI",
+ archivedAt: null,
+ startsAt: { lte: now },
+ endsAt: { gt: now },
+ AND: [{ OR: scopeFilter }],
+ },
+ include: {
+ interactions: {
+ where: { userId },
+ },
+ },
+ orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
+ });
+
+ const sorted = [...notifications].sort(compareNotifications);
+
+ // Global per-user request counter stored in Redis, used for cliShowEvery modulo.
+ // This is independent of per-notification showCount so that cliMaxShowCount
+ // correctly tracks actual displays, not API encounters.
+ const requestCounter = await incrementCliRequestCounter(userId);
+
+ for (const n of sorted) {
+ const interaction = n.interactions[0] ?? null;
+
+ if (interaction?.cliDismissedAt) continue;
+ if (isCliNotificationExpired(interaction, n)) continue;
+
+ const parsed = PayloadV1Schema.safeParse(n.payload);
+ if (!parsed.success) continue;
+
+ // Check cliShowEvery using the global request counter
+ if (n.cliShowEvery !== null && requestCounter % n.cliShowEvery !== 0) {
+ continue;
+ }
+
+ // Only increment showCount when the notification will actually be displayed.
+ // If this display reaches cliMaxShowCount, also set cliDismissedAt now
+ // so it's recorded immediately rather than waiting for a future request.
+ const reachedMaxShows =
+ n.cliMaxShowCount !== null &&
+ ((interaction?.showCount ?? 0) + 1) >= n.cliMaxShowCount;
+
+ const updated = await prisma.platformNotificationInteraction.upsert({
+ where: { notificationId_userId: { notificationId: n.id, userId } },
+ update: {
+ showCount: { increment: 1 },
+ ...(reachedMaxShows ? { cliDismissedAt: now } : {}),
+ },
+ create: {
+ notificationId: n.id,
+ userId,
+ firstSeenAt: now,
+ showCount: 1,
+ ...(reachedMaxShows ? { cliDismissedAt: now } : {}),
+ },
+ });
+
+ return {
+ id: n.id,
+ payload: parsed.data,
+ showCount: updated.showCount,
+ firstSeenAt: updated.firstSeenAt.toISOString(),
+ };
+ }
+
+ return null;
+}
+
+// --- Create: admin endpoint support ---
+
+const SCOPE_REQUIRED_FK: Record = {
+ USER: "userId",
+ ORGANIZATION: "organizationId",
+ PROJECT: "projectId",
+};
+
+const ALL_FK_FIELDS = ["userId", "organizationId", "projectId"] as const;
+const CLI_ONLY_FIELDS = ["cliMaxDaysAfterFirstSeen", "cliMaxShowCount", "cliShowEvery"] as const;
+
+export const CreatePlatformNotificationSchema = z
+ .object({
+ title: z.string().min(1),
+ payload: PayloadV1Schema,
+ surface: z.enum(["WEBAPP", "CLI"]),
+ scope: z.enum(["USER", "PROJECT", "ORGANIZATION", "GLOBAL"]),
+ userId: z.string().optional(),
+ organizationId: z.string().optional(),
+ projectId: z.string().optional(),
+ startsAt: z
+ .string()
+ .datetime()
+ .transform((s) => new Date(s))
+ .optional(),
+ endsAt: z
+ .string()
+ .datetime()
+ .transform((s) => new Date(s)),
+ priority: z.number().int().default(0),
+ cliMaxDaysAfterFirstSeen: z.number().int().positive().optional(),
+ cliMaxShowCount: z.number().int().positive().optional(),
+ cliShowEvery: z.number().int().min(2).optional(),
+ })
+ .superRefine((data, ctx) => {
+ validateScopeForeignKeys(data, ctx);
+ validateSurfaceFields(data, ctx);
+ validatePayloadTypeForSurface(data, ctx);
+ validateStartsAt(data, ctx);
+ validateEndsAt(data, ctx);
+ });
+
+function validateScopeForeignKeys(
+ data: { scope: string; userId?: string; organizationId?: string; projectId?: string },
+ ctx: z.RefinementCtx
+) {
+ const requiredFk = SCOPE_REQUIRED_FK[data.scope];
+
+ if (requiredFk && !data[requiredFk]) {
+ ctx.addIssue({
+ code: "custom",
+ message: `${requiredFk} is required when scope is ${data.scope}`,
+ path: [requiredFk],
+ });
+ }
+
+ const forbiddenFks = ALL_FK_FIELDS.filter((fk) => fk !== requiredFk);
+ for (const fk of forbiddenFks) {
+ if (data[fk]) {
+ ctx.addIssue({
+ code: "custom",
+ message: `${fk} must not be set when scope is ${data.scope}`,
+ path: [fk],
+ });
+ }
+ }
+}
+
+function validateSurfaceFields(
+ data: {
+ surface: string;
+ cliMaxDaysAfterFirstSeen?: number;
+ cliMaxShowCount?: number;
+ cliShowEvery?: number;
+ },
+ ctx: z.RefinementCtx
+) {
+ if (data.surface !== "WEBAPP") return;
+
+ for (const field of CLI_ONLY_FIELDS) {
+ if (data[field] !== undefined) {
+ ctx.addIssue({
+ code: "custom",
+ message: `${field} is not allowed for WEBAPP surface`,
+ path: [field],
+ });
+ }
+ }
+}
+
+function validateStartsAt(data: { startsAt?: Date }, ctx: z.RefinementCtx) {
+ if (!data.startsAt) return;
+
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
+ if (data.startsAt < oneHourAgo) {
+ ctx.addIssue({
+ code: "custom",
+ message: "startsAt must be within the last hour or in the future",
+ path: ["startsAt"],
+ });
+ }
+}
+
+const CLI_TYPES = new Set(["info", "warn", "error", "success"]);
+const WEBAPP_TYPES = new Set(["card", "changelog"]);
+
+function validatePayloadTypeForSurface(
+ data: { surface: string; payload: PayloadV1 },
+ ctx: z.RefinementCtx
+) {
+ const allowedTypes = data.surface === "CLI" ? CLI_TYPES : WEBAPP_TYPES;
+ if (!allowedTypes.has(data.payload.data.type)) {
+ ctx.addIssue({
+ code: "custom",
+ message: `payload.data.type "${data.payload.data.type}" is not allowed for ${data.surface} surface`,
+ path: ["payload", "data", "type"],
+ });
+ }
+}
+
+function validateEndsAt(data: { startsAt?: Date; endsAt: Date }, ctx: z.RefinementCtx) {
+ const effectiveStart = data.startsAt ?? new Date();
+ if (data.endsAt <= effectiveStart) {
+ ctx.addIssue({
+ code: "custom",
+ message: "endsAt must be after startsAt",
+ path: ["endsAt"],
+ });
+ }
+}
+
+export type CreatePlatformNotificationInput = z.input;
+
+type CreateError =
+ | { type: "validation"; issues: z.ZodIssue[] }
+ | { type: "db"; message: string };
+
+export function createPlatformNotification(
+ input: CreatePlatformNotificationInput
+): ResultAsync<{ id: string; friendlyId: string }, CreateError> {
+ const parseResult = CreatePlatformNotificationSchema.safeParse(input);
+
+ if (!parseResult.success) {
+ return errAsync({ type: "validation", issues: parseResult.error.issues });
+ }
+
+ const data = parseResult.data;
+
+ return fromPromise(
+ prisma.platformNotification.create({
+ data: {
+ title: data.title,
+ payload: data.payload,
+ surface: data.surface as PlatformNotificationSurface,
+ scope: data.scope as PlatformNotificationScope,
+ userId: data.userId,
+ organizationId: data.organizationId,
+ projectId: data.projectId,
+ startsAt: data.startsAt ?? new Date(),
+ endsAt: data.endsAt,
+ priority: data.priority,
+ cliMaxDaysAfterFirstSeen: data.cliMaxDaysAfterFirstSeen,
+ cliMaxShowCount: data.cliMaxShowCount,
+ cliShowEvery: data.cliShowEvery,
+ },
+ select: { id: true, friendlyId: true },
+ }),
+ (e): CreateError => ({
+ type: "db",
+ message: e instanceof Error ? e.message : String(e),
+ })
+ );
+}
diff --git a/apps/webapp/package.json b/apps/webapp/package.json
index 3eafd1467fc..e5c5fb472ba 100644
--- a/apps/webapp/package.json
+++ b/apps/webapp/package.json
@@ -191,6 +191,7 @@
"react-dom": "^18.2.0",
"react-grid-layout": "^2.2.2",
"react-hotkeys-hook": "^4.4.1",
+ "react-markdown": "^10.1.0",
"react-popper": "^2.3.0",
"react-resizable": "^3.1.3",
"react-resizable-panels": "^2.0.9",
diff --git a/internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql b/internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql
new file mode 100644
index 00000000000..ae76fad81c0
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql
@@ -0,0 +1,69 @@
+-- CreateEnum
+CREATE TYPE "public"."PlatformNotificationSurface" AS ENUM ('WEBAPP', 'CLI');
+
+-- CreateEnum
+CREATE TYPE "public"."PlatformNotificationScope" AS ENUM ('USER', 'PROJECT', 'ORGANIZATION', 'GLOBAL');
+
+-- CreateTable
+CREATE TABLE "public"."PlatformNotification" (
+ "id" TEXT NOT NULL,
+ "friendlyId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "payload" JSONB NOT NULL,
+ "surface" "public"."PlatformNotificationSurface" NOT NULL,
+ "scope" "public"."PlatformNotificationScope" NOT NULL,
+ "userId" TEXT,
+ "organizationId" TEXT,
+ "projectId" TEXT,
+ "startsAt" TIMESTAMP(3) NOT NULL,
+ "endsAt" TIMESTAMP(3) NOT NULL,
+ "cliMaxDaysAfterFirstSeen" INTEGER,
+ "cliMaxShowCount" INTEGER,
+ "cliShowEvery" INTEGER,
+ "priority" INTEGER NOT NULL DEFAULT 0,
+ "archivedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "PlatformNotification_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "public"."PlatformNotificationInteraction" (
+ "id" TEXT NOT NULL,
+ "notificationId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "showCount" INTEGER NOT NULL DEFAULT 1,
+ "webappDismissedAt" TIMESTAMP(3),
+ "webappClickedAt" TIMESTAMP(3),
+ "cliDismissedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "PlatformNotificationInteraction_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PlatformNotification_friendlyId_key" ON "public"."PlatformNotification"("friendlyId");
+
+-- CreateIndex
+CREATE INDEX "PlatformNotification_surface_scope_startsAt_idx" ON "public"."PlatformNotification"("surface", "scope", "startsAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PlatformNotificationInteraction_notificationId_userId_key" ON "public"."PlatformNotificationInteraction"("notificationId", "userId");
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotificationInteraction" ADD CONSTRAINT "PlatformNotificationInteraction_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "public"."PlatformNotification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotificationInteraction" ADD CONSTRAINT "PlatformNotificationInteraction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index ceb10f7549b..b60dcd7c9b0 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -65,6 +65,9 @@ model User {
impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget")
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
+
+ platformNotifications PlatformNotification[]
+ platformNotificationInteractions PlatformNotificationInteraction[]
}
model MfaBackupCode {
@@ -229,6 +232,8 @@ model Organization {
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
prompts Prompt[]
+
+ platformNotifications PlatformNotification[]
}
model OrgMember {
@@ -421,6 +426,8 @@ model Project {
metricsDashboards MetricsDashboard[]
llmModels LlmModel[]
prompts Prompt[]
+
+ platformNotifications PlatformNotification[]
}
enum ProjectVersion {
@@ -2719,3 +2726,102 @@ model LlmPrice {
@@unique([modelId, usageType, pricingTierId])
@@map("llm_prices")
}
+
+enum PlatformNotificationSurface {
+ WEBAPP
+ CLI
+}
+
+enum PlatformNotificationScope {
+ USER
+ PROJECT
+ ORGANIZATION
+ GLOBAL
+}
+
+/// Admin-created notification definitions
+model PlatformNotification {
+ id String @id @default(cuid())
+
+ friendlyId String @unique @default(cuid())
+
+ /// Admin-facing title for identification in admin tools
+ title String
+
+ /// Versioned JSON rendering content (see payload schema in spec)
+ payload Json
+
+ surface PlatformNotificationSurface
+ scope PlatformNotificationScope
+
+ /// Set when scope = USER
+ user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ userId String?
+
+ /// Set when scope = ORGANIZATION
+ organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ organizationId String?
+
+ /// Set when scope = PROJECT
+ project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ projectId String?
+
+ /// When notification becomes active
+ startsAt DateTime
+
+ /// When notification expires
+ endsAt DateTime
+
+ /// CLI: auto-expire N days after user first saw it
+ cliMaxDaysAfterFirstSeen Int?
+
+ /// CLI: auto-expire after shown N times to user
+ cliMaxShowCount Int?
+
+ /// CLI: only show every N-th request (e.g. cliShowEvery=3 means show on 3rd, 6th, 9th…)
+ cliShowEvery Int?
+
+ /// Ordering within same scope level (higher = more important)
+ priority Int @default(0)
+
+ /// Soft delete
+ archivedAt DateTime?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ interactions PlatformNotificationInteraction[]
+
+ @@index([surface, scope, startsAt])
+}
+
+/// Per-user tracking of notification views and dismissals
+model PlatformNotificationInteraction {
+ id String @id @default(cuid())
+
+ notification PlatformNotification @relation(fields: [notificationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ notificationId String
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ userId String
+
+ /// Set by beacon/CLI GET on first impression
+ firstSeenAt DateTime @default(now())
+
+ /// Times shown (incremented per beacon/CLI view)
+ showCount Int @default(1)
+
+ /// User dismissed in webapp
+ webappDismissedAt DateTime?
+
+ /// User clicked a link in webapp
+ webappClickedAt DateTime?
+
+ /// Auto-dismissed or expired in CLI
+ cliDismissedAt DateTime?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([notificationId, userId])
+}
diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts
index a22d0ca6151..c70d9b11419 100644
--- a/packages/cli-v3/src/apiClient.ts
+++ b/packages/cli-v3/src/apiClient.ts
@@ -58,6 +58,33 @@ import { z } from "zod";
import { logger } from "./utilities/logger.js";
import { VERSION } from "./version.js";
+const CliPlatformNotificationResponseSchema = z.object({
+ notification: z
+ .object({
+ id: z.string(),
+ payload: z.object({
+ version: z.string(),
+ data: z.object({
+ type: z.enum(["info", "warn", "error", "success"]),
+ title: z.string(),
+ description: z.string(),
+ actionLabel: z.string().optional(),
+ actionUrl: z.string().optional(),
+ discovery: z
+ .object({
+ filePatterns: z.array(z.string()),
+ contentPattern: z.string().optional(),
+ matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
+ })
+ .optional(),
+ }),
+ }),
+ showCount: z.number(),
+ firstSeenAt: z.string(),
+ })
+ .nullable(),
+});
+
export class CliApiClient {
private engineURL: string;
private source: "cli" | "mcp";
@@ -540,6 +567,25 @@ export class CliApiClient {
);
}
+ async getCliPlatformNotification(projectRef?: string, signal?: AbortSignal) {
+ if (!this.accessToken) {
+ return { success: true as const, data: { notification: null } };
+ }
+
+ const url = new URL("/api/v1/platform-notifications", this.apiURL);
+ if (projectRef) {
+ url.searchParams.set("projectRef", projectRef);
+ }
+
+ return wrapZodFetch(CliPlatformNotificationResponseSchema, url.href, {
+ headers: {
+ Authorization: `Bearer ${this.accessToken}`,
+ "Content-Type": "application/json",
+ },
+ signal,
+ });
+ }
+
async triggerTaskRun(taskId: string, body?: TriggerTaskRequestBody) {
if (!this.accessToken) {
throw new Error("triggerTaskRun: No access token");
diff --git a/packages/cli-v3/src/commands/dev.ts b/packages/cli-v3/src/commands/dev.ts
index 5557e595817..73e79933dd0 100644
--- a/packages/cli-v3/src/commands/dev.ts
+++ b/packages/cli-v3/src/commands/dev.ts
@@ -1,6 +1,7 @@
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
import { Command, Option as CommandOption } from "commander";
import { z } from "zod";
+import { CliApiClient } from "../apiClient.js";
import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js";
import { watchConfig } from "../config.js";
import { DevSessionInstance, startDevSession } from "../dev/devSession.js";
@@ -9,6 +10,10 @@ import { chalkError } from "../utilities/cliOutput.js";
import { resolveLocalEnvVars } from "../utilities/localEnvVars.js";
import { printDevBanner, printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
+import {
+ awaitAndDisplayPlatformNotification,
+ fetchPlatformNotification,
+} from "../utilities/platformNotifications.js";
import { runtimeChecks } from "../utilities/runtimeCheck.js";
import { getProjectClient, LoginResultOk } from "../utilities/session.js";
import { login } from "./login.js";
@@ -28,6 +33,7 @@ const DevCommandOptions = CommonCommandOptions.extend({
config: z.string().optional(),
projectRef: z.string().optional(),
skipUpdateCheck: z.boolean().default(false),
+ skipPlatformNotifications: z.boolean().default(false),
envFile: z.string().optional(),
keepTmpFiles: z.boolean().default(false),
maxConcurrentRuns: z.coerce.number().optional(),
@@ -97,6 +103,12 @@ export function configureDevCommand(program: Command) {
).hideHelp()
)
.addOption(new CommandOption("--disable-warnings", "Suppress warnings output").hideHelp())
+ .addOption(
+ new CommandOption(
+ "--skip-platform-notifications",
+ "Skip showing platform notifications"
+ ).hideHelp()
+ )
).action(async (options) => {
wrapCommandAction("dev", DevCommandOptions, options, async (opts) => {
await devCommand(opts);
@@ -205,8 +217,17 @@ async function startDev(options: StartDevOptions) {
logger.loggerLevel = options.logLevel;
}
+ const notificationPromise = options.skipPlatformNotifications
+ ? undefined
+ : fetchPlatformNotification({
+ apiClient: new CliApiClient(options.login.auth.apiUrl, options.login.auth.accessToken),
+ projectRef: options.projectRef,
+ });
+
await printStandloneInitialBanner(true, options.profile);
+ await awaitAndDisplayPlatformNotification(notificationPromise);
+
let displayedUpdateMessage = false;
if (!options.skipUpdateCheck) {
diff --git a/packages/cli-v3/src/commands/login.ts b/packages/cli-v3/src/commands/login.ts
index f3b46405a73..59af7ee895a 100644
--- a/packages/cli-v3/src/commands/login.ts
+++ b/packages/cli-v3/src/commands/login.ts
@@ -20,6 +20,10 @@ import {
writeAuthConfigCurrentProfileName,
} from "../utilities/configFiles.js";
import { printInitialBanner } from "../utilities/initialBanner.js";
+import {
+ awaitAndDisplayPlatformNotification,
+ fetchPlatformNotification,
+} from "../utilities/platformNotifications.js";
import { LoginResult } from "../utilities/session.js";
import { whoAmI } from "./whoami.js";
import { logger } from "../utilities/logger.js";
@@ -285,6 +289,17 @@ export async function login(options?: LoginOptions): Promise {
options?.profile
);
+ // Only fetch notifications for standalone login, not when embedded in dev
+ // (dev.ts handles its own notification fetch to avoid double counting)
+ const notificationPromise = opts.embedded
+ ? undefined
+ : fetchPlatformNotification({
+ apiClient: new CliApiClient(
+ authConfig?.apiUrl ?? opts.defaultApiUrl,
+ indexResult.token
+ ),
+ });
+
const whoAmIResult = await whoAmI(
{
profile: options?.profile ?? "default",
@@ -309,6 +324,8 @@ export async function login(options?: LoginOptions): Promise {
outro("Logged in successfully");
}
+ await awaitAndDisplayPlatformNotification(notificationPromise);
+
span.end();
return {
diff --git a/packages/cli-v3/src/utilities/colorMarkup.test.ts b/packages/cli-v3/src/utilities/colorMarkup.test.ts
new file mode 100644
index 00000000000..c6e64845ad2
--- /dev/null
+++ b/packages/cli-v3/src/utilities/colorMarkup.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect } from "vitest";
+import chalk from "chalk";
+import { applyColorMarkup } from "./colorMarkup.js";
+
+// Force chalk to produce color codes in tests
+chalk.level = 3;
+
+describe("applyColorMarkup", () => {
+ it("returns plain text when no markup is present", () => {
+ expect(applyColorMarkup("Hello world")).toBe("Hello world");
+ });
+
+ it("applies fallback style to plain text when provided", () => {
+ expect(applyColorMarkup("Hello", chalk.bold)).toBe(chalk.bold("Hello"));
+ });
+
+ it("applies a single color tag", () => {
+ expect(applyColorMarkup("{red}error{/red}")).toBe(chalk.red("error"));
+ });
+
+ it("applies color to a portion of text", () => {
+ const result = applyColorMarkup("Status: {green}OK{/green}");
+ expect(result).toBe("Status: " + chalk.green("OK"));
+ });
+
+ it("applies multiple color regions", () => {
+ const result = applyColorMarkup("{red}bad{/red} and {green}good{/green}");
+ expect(result).toBe(chalk.red("bad") + " and " + chalk.green("good"));
+ });
+
+ it("applies bold tag", () => {
+ expect(applyColorMarkup("{bold}important{/bold}")).toBe(chalk.bold("important"));
+ });
+
+ it("applies fallback to non-tagged text only", () => {
+ const result = applyColorMarkup("Please {red}upgrade{/red} now", chalk.bold);
+ expect(result).toBe(chalk.bold("Please ") + chalk.red("upgrade") + chalk.bold(" now"));
+ });
+
+ it("passes through invalid tag names as literal text", () => {
+ expect(applyColorMarkup("Hello {foo}world{/foo}")).toBe("Hello {foo}world{/foo}");
+ });
+
+ it("passes through braces with non-tag content", () => {
+ expect(applyColorMarkup("Use {curly} braces")).toBe("Use {curly} braces");
+ });
+
+ it("falls back on unclosed tag", () => {
+ const result = applyColorMarkup("{red}oops", chalk.bold);
+ expect(result).toBe(chalk.bold("{red}oops"));
+ });
+
+ it("falls back on mismatched close tag", () => {
+ const result = applyColorMarkup("{red}text{/green}", chalk.bold);
+ expect(result).toBe(chalk.bold("{red}text{/green}"));
+ });
+
+ it("falls back on nested tags", () => {
+ const result = applyColorMarkup("{red}{bold}nested{/bold}{/red}", chalk.bold);
+ expect(result).toBe(chalk.bold("{red}{bold}nested{/bold}{/red}"));
+ });
+
+ it("falls back on close tag without open", () => {
+ const result = applyColorMarkup("text{/red}");
+ expect(result).toBe("text{/red}");
+ });
+
+ it("handles empty string", () => {
+ expect(applyColorMarkup("")).toBe("");
+ });
+
+ it("handles tag with empty content", () => {
+ expect(applyColorMarkup("{red}{/red}")).toBe(chalk.red(""));
+ });
+
+ it("handles all valid color tags", () => {
+ const tags = [
+ "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray",
+ "redBright", "greenBright", "yellowBright", "blueBright",
+ "magentaBright", "cyanBright", "whiteBright",
+ ];
+ for (const tag of tags) {
+ const result = applyColorMarkup(`{${tag}}test{/${tag}}`);
+ const expected = (chalk as Record string>)[tag]("test");
+ expect(result).toBe(expected);
+ }
+ });
+
+ it("handles text with only braces but no valid tags", () => {
+ expect(applyColorMarkup("{} and {/}")).toBe("{} and {/}");
+ });
+});
diff --git a/packages/cli-v3/src/utilities/colorMarkup.ts b/packages/cli-v3/src/utilities/colorMarkup.ts
new file mode 100644
index 00000000000..17c45eea3f6
--- /dev/null
+++ b/packages/cli-v3/src/utilities/colorMarkup.ts
@@ -0,0 +1,139 @@
+import chalk from "chalk";
+
+const VALID_TAGS = new Set([
+ "red",
+ "green",
+ "yellow",
+ "blue",
+ "magenta",
+ "cyan",
+ "white",
+ "gray",
+ "redBright",
+ "greenBright",
+ "yellowBright",
+ "blueBright",
+ "magentaBright",
+ "cyanBright",
+ "whiteBright",
+ "bold",
+]);
+
+type Token = { type: "text"; value: string } | { type: "styled"; tag: string; value: string };
+
+/**
+ * Parse `{tag}text{/tag}` markup and apply chalk colors.
+ * On malformed input (unclosed, mismatched, or nested tags), returns the entire
+ * string styled with `fallbackStyle` (or unstyled if no fallback).
+ */
+export function applyColorMarkup(
+ text: string,
+ fallbackStyle?: (t: string) => string
+): string {
+ const tokens = tokenize(text);
+ if (!tokens) {
+ // Malformed markup — apply fallback to entire string
+ return fallbackStyle ? fallbackStyle(text) : text;
+ }
+
+ if (tokens.length === 0) return "";
+
+ return tokens
+ .map((token) => {
+ if (token.type === "text") {
+ return fallbackStyle ? fallbackStyle(token.value) : token.value;
+ }
+ const colorFn = (chalk as unknown as Record)[token.tag];
+ if (typeof colorFn === "function") {
+ return (colorFn as (t: string) => string)(token.value);
+ }
+ return fallbackStyle ? fallbackStyle(token.value) : token.value;
+ })
+ .join("");
+}
+
+/**
+ * Tokenize a string with `{tag}...{/tag}` markup.
+ * Returns null if the markup is malformed (unclosed, mismatched, or nested tags).
+ * Braces with unrecognized tag names pass through as literal text.
+ */
+function tokenize(text: string): Token[] | null {
+ const tokens: Token[] = [];
+ let pos = 0;
+ let currentText = "";
+ let insideTag: string | null = null;
+
+ while (pos < text.length) {
+ const braceIdx = text.indexOf("{", pos);
+
+ if (braceIdx === -1) {
+ currentText += text.slice(pos);
+ break;
+ }
+
+ const closeIdx = text.indexOf("}", braceIdx);
+ if (closeIdx === -1) {
+ // No closing brace — treat rest as literal
+ currentText += text.slice(pos);
+ break;
+ }
+
+ const tagContent = text.slice(braceIdx + 1, closeIdx);
+
+ // Check for closing tag
+ if (tagContent.startsWith("/")) {
+ const closingName = tagContent.slice(1);
+
+ if (VALID_TAGS.has(closingName)) {
+ if (insideTag === null) {
+ // Close tag without open — malformed
+ return null;
+ }
+ if (insideTag !== closingName) {
+ // Mismatched close — malformed
+ return null;
+ }
+
+ // Add text before this brace to the styled content
+ currentText += text.slice(pos, braceIdx);
+ tokens.push({ type: "styled", tag: insideTag, value: currentText });
+ currentText = "";
+ insideTag = null;
+ pos = closeIdx + 1;
+ continue;
+ }
+ }
+
+ // Check for opening tag
+ if (VALID_TAGS.has(tagContent)) {
+ if (insideTag !== null) {
+ // Nesting — malformed
+ return null;
+ }
+
+ currentText += text.slice(pos, braceIdx);
+ if (currentText) {
+ tokens.push({ type: "text", value: currentText });
+ currentText = "";
+ }
+ insideTag = tagContent;
+ pos = closeIdx + 1;
+ continue;
+ }
+
+ // Not a recognized tag — treat braces as literal text
+ currentText += text.slice(pos, closeIdx + 1);
+ pos = closeIdx + 1;
+ }
+
+ // If we're still inside a tag at the end, that's malformed
+ if (insideTag !== null) {
+ return null;
+ }
+
+ if (currentText) {
+ tokens.push({ type: "text", value: currentText });
+ }
+
+ return tokens;
+}
diff --git a/packages/cli-v3/src/utilities/discoveryCheck.test.ts b/packages/cli-v3/src/utilities/discoveryCheck.test.ts
new file mode 100644
index 00000000000..8c4b75420c1
--- /dev/null
+++ b/packages/cli-v3/src/utilities/discoveryCheck.test.ts
@@ -0,0 +1,212 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import path from "node:path";
+import fs from "node:fs/promises";
+import os from "node:os";
+import { evaluateDiscovery, type DiscoverySpec } from "./discoveryCheck.js";
+
+let tmpDir: string;
+
+beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "discovery-test-"));
+});
+
+afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+});
+
+describe("evaluateDiscovery", () => {
+ describe("show-if-found with file existence", () => {
+ it("returns true when file exists", async () => {
+ await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("returns false when file does not exist", async () => {
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
+ });
+ });
+
+ describe("show-if-not-found with file existence", () => {
+ it("returns false when file exists", async () => {
+ await fs.writeFile(path.join(tmpDir, ".mcp.json"), "{}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json"],
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
+ });
+
+ it("returns true when file does not exist", async () => {
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json"],
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+ });
+
+ describe("content pattern matching", () => {
+ it("show-if-found: returns true when content matches", async () => {
+ await fs.writeFile(
+ path.join(tmpDir, "trigger.config.ts"),
+ 'import { syncVercelEnvVars } from "@trigger.dev/build";'
+ );
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts"],
+ contentPattern: "syncVercelEnvVars",
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("show-if-found: returns false when file exists but content does not match", async () => {
+ await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts"],
+ contentPattern: "syncVercelEnvVars",
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
+ });
+
+ it("show-if-not-found: returns true when file exists but content does not match", async () => {
+ await fs.writeFile(path.join(tmpDir, ".mcp.json"), '{"mcpServers": {}}');
+
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json"],
+ contentPattern: "trigger",
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("show-if-not-found: returns false when content matches", async () => {
+ await fs.writeFile(
+ path.join(tmpDir, ".mcp.json"),
+ '{"mcpServers": {"trigger": {"url": "https://mcp.trigger.dev"}}}'
+ );
+
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json"],
+ contentPattern: "trigger",
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
+ });
+
+ it("supports regex content patterns", async () => {
+ await fs.writeFile(path.join(tmpDir, "config.ts"), "syncVercelEnvVars({ foo: true })");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["config.ts"],
+ contentPattern: "syncVercel\\w+",
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+ });
+
+ describe("glob patterns", () => {
+ it("matches files with glob patterns", async () => {
+ await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.*"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("matches files in subdirectories with glob", async () => {
+ await fs.mkdir(path.join(tmpDir, ".cursor"), { recursive: true });
+ await fs.writeFile(path.join(tmpDir, ".cursor", "mcp.json"), "{}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: [".cursor/mcp.json"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+ });
+
+ describe("multiple file patterns", () => {
+ it("returns true if any pattern matches (show-if-found)", async () => {
+ await fs.writeFile(path.join(tmpDir, "trigger.config.js"), "module.exports = {}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts", "trigger.config.js"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("returns true only if no pattern matches (show-if-not-found)", async () => {
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json", ".cursor/mcp.json"],
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("content match short-circuits on first matching file", async () => {
+ await fs.writeFile(path.join(tmpDir, "a.ts"), "no match here");
+ await fs.writeFile(path.join(tmpDir, "b.ts"), "syncVercelEnvVars found");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["a.ts", "b.ts"],
+ contentPattern: "syncVercelEnvVars",
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+ });
+
+ describe("error handling (fail closed)", () => {
+ it("returns false when file cannot be read for content check", async () => {
+ // Create a file then make it unreadable
+ const filePath = path.join(tmpDir, "unreadable.ts");
+ await fs.writeFile(filePath, "content");
+ await fs.chmod(filePath, 0o000);
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["unreadable.ts"],
+ contentPattern: "content",
+ matchBehavior: "show-if-found",
+ };
+
+ // On some systems (e.g., running as root), chmod may not restrict reads
+ // So we just verify it doesn't throw
+ const result = await evaluateDiscovery(spec, tmpDir);
+ expect(typeof result).toBe("boolean");
+
+ // Restore permissions for cleanup
+ await fs.chmod(filePath, 0o644);
+ });
+ });
+});
diff --git a/packages/cli-v3/src/utilities/discoveryCheck.ts b/packages/cli-v3/src/utilities/discoveryCheck.ts
new file mode 100644
index 00000000000..8f0ceb46a64
--- /dev/null
+++ b/packages/cli-v3/src/utilities/discoveryCheck.ts
@@ -0,0 +1,168 @@
+import { glob, isDynamicPattern } from "tinyglobby";
+import { tryCatch } from "@trigger.dev/core/utils";
+import { expandTilde, pathExists, readFile } from "./fileSystem.js";
+import { logger } from "./logger.js";
+import path from "node:path";
+
+export type DiscoverySpec = {
+ filePatterns: string[];
+ contentPattern?: string;
+ matchBehavior: "show-if-found" | "show-if-not-found";
+};
+
+const REGEX_METACHARACTERS = /[\\^$.|?*+(){}[\]]/;
+
+/**
+ * Evaluates a discovery spec against the local filesystem.
+ * Returns `true` if the notification should be shown, `false` otherwise.
+ * Fails closed: any error returns `false` (suppress notification).
+ */
+export async function evaluateDiscovery(
+ spec: DiscoverySpec,
+ projectRoot: string
+): Promise {
+ const [error, result] = await tryCatch(doEvaluate(spec, projectRoot));
+
+ if (error) {
+ logger.debug("Discovery check failed, suppressing notification", { error });
+ return false;
+ }
+
+ return result;
+}
+
+async function doEvaluate(spec: DiscoverySpec, projectRoot: string): Promise {
+ logger.debug("Discovery: starting evaluation", {
+ filePatterns: spec.filePatterns,
+ contentPattern: spec.contentPattern,
+ matchBehavior: spec.matchBehavior,
+ projectRoot,
+ });
+
+ const matchedFiles = await resolveFilePatterns(spec.filePatterns, projectRoot);
+ const hasFileMatch = matchedFiles.length > 0;
+
+ if (!hasFileMatch) {
+ const result = spec.matchBehavior === "show-if-not-found";
+ logger.debug("Discovery: no files matched any pattern", { result });
+ return result;
+ }
+
+ // Files matched — if no content pattern, decide based on file match alone
+ if (!spec.contentPattern) {
+ const result = spec.matchBehavior === "show-if-found";
+ logger.debug("Discovery: files matched, no content pattern to check", {
+ matchedFiles,
+ result,
+ });
+ return result;
+ }
+
+ // Check content in matched files
+ const hasContentMatch = await checkContentPattern(matchedFiles, spec.contentPattern);
+
+ const result =
+ spec.matchBehavior === "show-if-found" ? hasContentMatch : !hasContentMatch;
+
+ logger.debug("Discovery: evaluation complete", {
+ matchedFiles,
+ contentPattern: spec.contentPattern,
+ hasContentMatch,
+ result,
+ });
+
+ return result;
+}
+
+async function resolveFilePatterns(
+ patterns: string[],
+ projectRoot: string
+): Promise {
+ const matched: string[] = [];
+
+ for (const pattern of patterns) {
+ const isHomeDirPattern = pattern.startsWith("~/");
+ const resolvedPattern = isHomeDirPattern ? expandTilde(pattern) : pattern;
+ const cwd = isHomeDirPattern ? "/" : projectRoot;
+ const isGlob = isDynamicPattern(resolvedPattern);
+
+ logger.debug("Discovery: resolving pattern", {
+ pattern,
+ resolvedPattern,
+ cwd,
+ isGlob,
+ isHomeDirPattern,
+ });
+
+ if (isGlob) {
+ const files = await glob({
+ patterns: [resolvedPattern],
+ cwd,
+ absolute: true,
+ dot: true,
+ });
+ if (files.length > 0) {
+ logger.debug("Discovery: glob matched files", { pattern, files });
+ }
+ matched.push(...files);
+ } else {
+ const absolutePath = isHomeDirPattern
+ ? resolvedPattern
+ : path.resolve(projectRoot, resolvedPattern);
+ const exists = await pathExists(absolutePath);
+ logger.debug("Discovery: literal path check", { pattern, absolutePath, exists });
+ if (exists) {
+ matched.push(absolutePath);
+ }
+ }
+ }
+
+ return matched;
+}
+
+async function checkContentPattern(
+ files: string[],
+ contentPattern: string
+): Promise {
+ const useFastPath = !REGEX_METACHARACTERS.test(contentPattern);
+
+ // Pre-compile regex once outside the loop to avoid repeated compilation
+ // and to catch invalid patterns early
+ let regex: RegExp | undefined;
+ if (!useFastPath) {
+ try {
+ regex = new RegExp(contentPattern);
+ } catch (error) {
+ logger.debug("Discovery: invalid regex pattern, skipping content check", {
+ contentPattern,
+ error,
+ });
+ return false;
+ }
+ }
+
+ logger.debug("Discovery: checking content pattern", {
+ contentPattern,
+ useFastPath,
+ fileCount: files.length,
+ });
+
+ for (const filePath of files) {
+ const [error, content] = await tryCatch(readFile(filePath));
+
+ if (error) {
+ logger.debug("Discovery: failed to read file, skipping", { filePath, error });
+ continue;
+ }
+
+ const matches = useFastPath ? content.includes(contentPattern) : regex!.test(content);
+
+ logger.debug("Discovery: content check result", { filePath, matches });
+
+ if (matches) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/packages/cli-v3/src/utilities/platformNotifications.ts b/packages/cli-v3/src/utilities/platformNotifications.ts
new file mode 100644
index 00000000000..cfdbc83ff48
--- /dev/null
+++ b/packages/cli-v3/src/utilities/platformNotifications.ts
@@ -0,0 +1,126 @@
+import { log } from "@clack/prompts";
+import chalk from "chalk";
+import { tryCatch } from "@trigger.dev/core/utils";
+import { CliApiClient } from "../apiClient.js";
+import { chalkGrey } from "./cliOutput.js";
+import { applyColorMarkup } from "./colorMarkup.js";
+import { evaluateDiscovery } from "./discoveryCheck.js";
+import { logger } from "./logger.js";
+import { spinner } from "./windows.js";
+
+type CliLogLevel = "info" | "warn" | "error" | "success";
+
+type PlatformNotification = {
+ level: CliLogLevel;
+ title: string;
+ description: string;
+ actionUrl?: string;
+};
+
+type FetchNotificationOptions = {
+ apiClient: CliApiClient;
+ projectRef?: string;
+ projectRoot?: string;
+};
+
+export async function fetchPlatformNotification(
+ options: FetchNotificationOptions
+): Promise {
+ const [error, result] = await tryCatch(
+ options.apiClient.getCliPlatformNotification(
+ options.projectRef,
+ AbortSignal.timeout(7000)
+ )
+ );
+
+ if (error) {
+ logger.debug("Platform notifications failed silently", { error });
+ return undefined;
+ }
+
+ if (!result.success) {
+ logger.debug("Platform notification fetch failed", { result });
+ return undefined;
+ }
+
+ const notification = result.data.notification;
+ if (!notification) return undefined;
+
+ const { type, discovery, title, description, actionUrl } = notification.payload.data;
+
+ if (discovery) {
+ const root = options.projectRoot ?? process.cwd();
+ const shouldShow = await evaluateDiscovery(discovery, root);
+ if (!shouldShow) {
+ logger.debug("Notification suppressed by discovery check", {
+ notificationId: notification.id,
+ discovery,
+ });
+ return undefined;
+ }
+ }
+
+ return { level: type, title, description, actionUrl };
+}
+
+function displayPlatformNotification(
+ notification: PlatformNotification | undefined
+): void {
+ if (!notification) return;
+
+ const message = formatNotificationMessage(notification);
+ log[notification.level](message);
+}
+
+function formatNotificationMessage(notification: PlatformNotification): string {
+ const { title, description, actionUrl } = notification;
+ const styledTitle = applyColorMarkup(title, (t) => chalk.bold(t));
+ const styledDescription = applyColorMarkup(description, chalkGrey);
+ const lines = [styledTitle, styledDescription];
+ if (actionUrl) {
+ lines.push(chalk.underline(chalkGrey(actionUrl)));
+ }
+ return lines.join("\n");
+}
+
+const SPINNER_DELAY_MS = 200;
+
+/**
+ * Awaits a notification promise, showing a loading spinner if the fetch
+ * takes longer than 200ms. The spinner is replaced by the notification
+ * content, or removed cleanly if there's nothing to show.
+ */
+export async function awaitAndDisplayPlatformNotification(
+ notificationPromise: Promise | undefined
+): Promise {
+ if (!notificationPromise) return;
+
+ try {
+ // Race against a short delay — if the promise resolves quickly, skip the spinner
+ const pending = Symbol("pending");
+ const raceResult = await Promise.race([
+ notificationPromise,
+ new Promise((resolve) =>
+ setTimeout(() => resolve(pending), SPINNER_DELAY_MS)
+ ),
+ ]);
+
+ if (raceResult !== pending) {
+ displayPlatformNotification(raceResult);
+ return;
+ }
+
+ // Still pending after delay — show a spinner while waiting
+ const $spinner = spinner();
+ $spinner.start("Checking for notifications");
+ const notification = await notificationPromise;
+
+ if (notification) {
+ $spinner.stop(formatNotificationMessage(notification));
+ } else {
+ $spinner.stop("No new notifications");
+ }
+ } catch (error) {
+ logger.debug("Platform notification display failed silently", { error });
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7d96d581f40..192a5747f2a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -710,6 +710,9 @@ importers:
react-hotkeys-hook:
specifier: ^4.4.1
version: 4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@18.2.69)(react@18.2.0)
react-popper:
specifier: ^2.3.0
version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)