feat(Notifications): Platform notifications for CLI and Dashboard (#3254)
For human reviewer: - Check if Redis connection + code makes sense - Check CLI methods (it's on a hotpath) - Check DB Migrations and new tables ## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing Spawning new CLI / Dashboard notifications, check MVP, check if failures not produce any problems with CLI/Dashboard --- ## Changelog Added notifications mechanism for Dashboard and CLI --- ## Screenshots 💯
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon={StarIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-sun-500"
|
||||
activeIconColor="text-sun-500"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
<Shortcuts />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">Need help?</Paragraph>
|
||||
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
|
||||
<div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={SlackIcon}
|
||||
data-action="join-our-slack"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="text-text-bright">Join our Slack…</span>
|
||||
<Badge variant="extra-small" className="uppercase">
|
||||
Pro
|
||||
</Badge>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Join our Slack</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
|
||||
<Paragraph variant="base/bright">
|
||||
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
|
||||
support with the Trigger.dev team.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<hr className="border-charcoal-800" />
|
||||
<div>
|
||||
<StepNumber stepNumber="1" title="Email us" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Send us an email to this address from your Trigger.dev account email
|
||||
address:
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
value="priority-support@trigger.dev"
|
||||
className="my-2"
|
||||
/>
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Look out for an invite from Slack" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
As soon as we can, we'll setup a Slack Connect channel and say hello!
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Ask in our Discord"
|
||||
icon={DiscordIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Book a 15 min call"
|
||||
icon={CalendarDaysIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-rose-500"
|
||||
activeIconColor="text-rose-500"
|
||||
to="https://cal.com/team/triggerdotdev/founders-call"
|
||||
data-action="book-a-call"
|
||||
target="_blank"
|
||||
/>
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
@@ -246,9 +159,44 @@ export function HelpAndFeedback({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">What's new</Paragraph>
|
||||
{changelogs.map((entry) => (
|
||||
<SideMenuItem
|
||||
key={entry.id}
|
||||
name={entry.title}
|
||||
icon={GrayDotIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
activeIconColor="text-text-dimmed"
|
||||
to={entry.actionUrl ?? "https://trigger.dev/changelog"}
|
||||
target="_blank"
|
||||
/>
|
||||
))}
|
||||
<SideMenuItem
|
||||
name="Full changelog"
|
||||
icon={StarIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
activeIconColor="text-text-dimmed"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="full-changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrayDotIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("flex items-center justify-center", className)}>
|
||||
<span className="block h-1.5 w-1.5 rounded-full bg-text-dimmed" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const dismissFetcher = useFetcher();
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const seenFetcher = useFetcher();
|
||||
const clickedIdsRef = useRef<Set<string>>(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 = (
|
||||
<NotificationCard
|
||||
notification={notification}
|
||||
onDismiss={handleDismiss}
|
||||
onLinkClick={() => fireClickBeacon(notification.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<div className="p-1">
|
||||
{/* Expanded sidebar: show card directly */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{card}
|
||||
</motion.div>
|
||||
|
||||
{/* Collapsed sidebar: show bell icon that opens popover */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isCollapsed ? "auto" : 0,
|
||||
opacity: isCollapsed ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger className="flex !h-8 w-full items-center justify-center rounded border border-charcoal-650 bg-charcoal-750/50 transition-colors hover:border-charcoal-600 hover:bg-charcoal-700/50">
|
||||
<div className="relative">
|
||||
<BellAlertIcon className="size-5 text-text-dimmed" />
|
||||
<span
|
||||
className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium text-white"
|
||||
style={{ backgroundColor: "#6366f1" }}
|
||||
>
|
||||
{visibleNotifications.length}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content="Notifications"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
disableHoverableContent
|
||||
asChild
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
<PopoverContent side="right" sideOffset={8} align="start" className="w-56 !min-w-0 p-0">
|
||||
{card}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(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 (
|
||||
<Wrapper
|
||||
{...wrapperProps}
|
||||
className="group/card group relative block overflow-hidden rounded border transition-colors border-grid-bright bg-charcoal-750/50 no-underline"
|
||||
>
|
||||
{/* Header: title + dismiss */}
|
||||
<div className="relative flex items-start gap-1 px-2 pt-1.5">
|
||||
<Header3 className="flex-1 !text-xs">
|
||||
{title}
|
||||
</Header3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="shrink-0 rounded p-0.5 text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body: description + chevron */}
|
||||
<div className="relative px-2 pb-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
ref={descriptionRef}
|
||||
className={cn(!isExpanded && "line-clamp-3")}
|
||||
>
|
||||
<ReactMarkdown components={getMarkdownComponents(onLinkClick)}>{description}</ReactMarkdown>
|
||||
</div>
|
||||
{(isOverflowing || isExpanded) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleExpand}
|
||||
className="mt-0.5 text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
{isExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionUrl && (
|
||||
<div className="mt-1 flex shrink-0 items-center pb-1 text-text-dimmed group-hover/card:text-text-bright transition-colors">
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<img src={sanitizeImageUrl(image)} alt="" className="mt-1.5 rounded" />
|
||||
)}
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 }) => (
|
||||
<p className="my-0.5 text-xs leading-relaxed text-text-dimmed">{children}</p>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-400 underline hover:text-indigo-300 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLinkClick();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className="font-semibold text-text-bright">{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
|
||||
code: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className="rounded bg-charcoal-700 px-1 py-0.5 text-[11px]">{children}</code>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
<NotificationPanel
|
||||
isCollapsed={isCollapsed}
|
||||
hasIncident={incidentStatus.hasIncident}
|
||||
organizationId={organization.id}
|
||||
projectId={project.id}
|
||||
/>
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { err, ok, type Result } from "neverthrow";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import {
|
||||
createPlatformNotification,
|
||||
type CreatePlatformNotificationInput,
|
||||
} from "~/services/platformNotifications.server";
|
||||
|
||||
type AdminUser = { id: string; admin: boolean };
|
||||
type AuthError = { status: number; message: string };
|
||||
|
||||
async function authenticateAdmin(request: Request): Promise<Result<AdminUser, AuthError>> {
|
||||
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 });
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,10 @@ export default function Page() {
|
||||
label: "LLM Models",
|
||||
to: "/admin/llm-models",
|
||||
},
|
||||
{
|
||||
label: "Notifications",
|
||||
to: "/admin/notifications",
|
||||
},
|
||||
]}
|
||||
layoutId={"admin"}
|
||||
/>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<PlatformChangelogsLoaderData>({ changelogs });
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
export function useRecentChangelogs() {
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<PlatformNotificationsLoaderData>({ notifications: [], unreadCount: 0 });
|
||||
}
|
||||
|
||||
const result = await getActivePlatformNotifications({ userId, organizationId, projectId });
|
||||
|
||||
return json<PlatformNotificationsLoaderData>(result);
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60000; // 1 minute
|
||||
|
||||
export function usePlatformNotifications(organizationId: string, projectId: string) {
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const lastLoadedUrl = useRef<string | null>(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",
|
||||
};
|
||||
}
|
||||
@@ -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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof PayloadV1Schema>;
|
||||
|
||||
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<string, unknown>;
|
||||
onCreate: Record<string, unknown>;
|
||||
}) {
|
||||
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<Record<string, unknown>> = [
|
||||
{ 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<string, "userId" | "organizationId" | "projectId"> = {
|
||||
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<typeof CreatePlatformNotificationSchema>;
|
||||
|
||||
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),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+69
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<LoginResult> {
|
||||
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<LoginResult> {
|
||||
outro("Logged in successfully");
|
||||
}
|
||||
|
||||
await awaitAndDisplayPlatformNotification(notificationPromise);
|
||||
|
||||
span.end();
|
||||
|
||||
return {
|
||||
|
||||
@@ -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, (s: string) => string>)[tag]("test");
|
||||
expect(result).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles text with only braces but no valid tags", () => {
|
||||
expect(applyColorMarkup("{} and {/}")).toBe("{} and {/}");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>)[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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<PlatformNotification | undefined> {
|
||||
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<PlatformNotification | undefined> | undefined
|
||||
): Promise<void> {
|
||||
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<typeof pending>((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 });
|
||||
}
|
||||
}
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user