Compare commits

...

40 Commits

Author SHA1 Message Date
Matt Aitken 17df4839d7 Latest lockfile
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 5s
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-01-21 17:30:21 +00:00
github-actions[bot] e3f78178f7 chore: Update version for release (#864)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-21 17:29:23 +00:00
Kritik Jiyaviya da90ee13c3 fix: Resend integration example comment (#860) 2024-01-21 17:16:26 +00:00
Matt Aitken 583da458ec Changed the minimum interval time period from 60s to 20s (with changeset) 2024-01-21 17:06:09 +00:00
Matt Aitken dcf95c4eb2 Revert "Changeset: Changed the minimum interval time from 60s to 20s"
This reverts commit c272e38de2.
2024-01-21 17:04:11 +00:00
Matt Aitken 32cf5790cb Revert "Changed the minimum interval time from 60s to 20s"
This reverts commit ca78ddc2c2.
2024-01-21 17:04:11 +00:00
Matt Aitken c272e38de2 Changeset: Changed the minimum interval time from 60s to 20s 2024-01-21 16:29:54 +00:00
Matt Aitken ca78ddc2c2 Changed the minimum interval time from 60s to 20s 2024-01-21 16:28:28 +00:00
Matt Aitken 07ed8c346a Fix for enabled env var in docs 2024-01-20 14:01:56 +00:00
Matt Aitken d4391f2e2d Fixed link in io.logger to the run log 2024-01-20 12:54:11 +00:00
Eric Allam 2c328ba93d Fix pnmp lock file again 2024-01-17 14:50:04 +00:00
github-actions[bot] 68dbc07ee8 chore: Update version for release (#854)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-17 14:49:19 +00:00
Eric Allam 58448243af Fix pnpm i 2024-01-17 14:41:51 +00:00
Eric Allam 6c4047cf21 Fix an issue where runs were stuck executing when a child task failed and the parent task retried 2024-01-17 14:39:06 +00:00
github-actions[bot] 831860eace chore: Update version for release (#853)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-17 14:13:34 +00:00
Matt Aitken db46f2a69a Add explicit return type to deprecated sendEmail function because tsup was complaining 2024-01-17 14:07:45 +00:00
Kritik Jiyaviya 4dd6cf18dc feat: Add support for the new Resend audience API endpoints (#850)
* feat: Add support for the new Resend audience API endpoints

* update pnpm-lock.yaml

* update pnpm-lock.yaml

* Merge branch 'main' into feat/support-for-resend-audience-api

* latest pnpm-lock.yaml

* Create lovely-items-end.md

* Update pnpm-lock.yaml

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-17 14:06:45 +00:00
Eric Allam adf66d23b8 Update pnpm lock file
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 15s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-01-17 11:15:30 +00:00
github-actions[bot] 5c42831a60 chore: Update version for release (#846)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-17 11:14:52 +00:00
Eric Allam 26f5e7774d Fix yalt.dev connection timeouts by increasing timeout from 1s to 5s (#851)
* Increase the yalt tunnel connection timeout to 5 seconds

* Add changeset
2024-01-17 10:27:57 +00:00
Matt Aitken f209a3b364 Ability to disable organization runs + more obvious if you're over the free limit (#848)
* Fix for typo in error when a Job invoke fails

* Can disable runs for an Organization

* Don’t perform runs if runsEnabled === false for an Org

* Exceeded runs messages made into errors, they were very subtle

* Return the runsEnabled property for each org

* More obvious message if you’re over the free run limit

* Added a red border to the free usage panel if you’re over your run limit

* Added missing property to Storybook for the FreePlanUsage component
2024-01-17 09:42:58 +00:00
Son Yen Nguyen a93b554f8b Feat: Update cron parser human readable string (#847)
* Update cron parser human readable string

* Create hot-cobras-flow.md

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-16 17:22:35 +00:00
Matt Aitken 0f342cd1be Don't show duplicate Job warning if it's an internal job 2024-01-16 15:54:20 +00:00
Matt Aitken 7df2f85a1f Latest lockfile 2024-01-16 15:22:21 +00:00
Matt Aitken f14180d13c Also hide "dev.trigger.scheduled" events from the event list 2024-01-16 15:21:45 +00:00
github-actions[bot] ab6b9514cd chore: Update version for release (#844)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-16 15:00:28 +00:00
Matt Aitken 98345d67d9 Allow unchecked array access in the CLI tsconfig. This matches the other packages… 2024-01-16 14:46:31 +00:00
Matt Aitken 38f5a90399 Auto-yielding is skipped for tasks that are no-ops and subtasks 2024-01-16 14:01:05 +00:00
Matt Aitken 1b2635ae4a Don’t show “trigger.scheduled” events in the list 2024-01-16 12:14:08 +00:00
Matt Aitken 129f023d11 EventTrigger source wasn’t getting passed through to the class 2024-01-16 12:07:11 +00:00
Matt Aitken 795e637dec caniuse-lite updated, it was logging out an annoying message 2024-01-16 11:52:08 +00:00
Matt Aitken 5238c424fc Absolute date filtering (#845)
* WIP on absolute date filtering for runs

* Use the date hook instead

* Reworked the date field again so the state behaves nicely

* Way better date filtering

* Setting the absolute date is working well, also clearing filters

* Reverse date format

* Turn off the guide

* Added time filtering and clearing to the events page
2024-01-16 11:44:15 +00:00
Matt Aitken 1bbd7e6dc3 V3 CLI started, with Personal Access Tokens and Who Am I (#841)
* Initial CLI commit

* Hooked up the CLI so it actually shows stuff…

* Totally reworked the v3 CLI to be based on our existing CLI (Commander)

* WIP on PersonalAccessTokens and AuthorizationCodes

* WIP creating a Personal Access Tokens page. Created a new sidenav for account pages

* Creating tokens is working but the form is broken

* Tokens are created in the UI

* Creating and revoking access tokens from the UI is working

* Improved the create form and copy

* Tokens are a bit shorter and only lowercase

* API endpoint for creating AuthorizationCodes and the web page users hit to create PATs from them

* V3_ENABLED env var and hook that can be used in the UI to show/hide things

* API route to get a PAT (within 10 mins of creating an auth code). Moved some code to core

* Start to build the login command

* Nicer banner when starting the CLI

* Nicer update checking

* Update command style improved

* Removed the template step

* Login command options are now working

* The new CLI is logging in using the Personal Access TOken. But I need to save it still

* Logging in and saving the token is working

* Deal with already being logged in

* Who Am I working with PAT

* Deleted old account side menu header

* Improved the copy on the Auth code page

* Added docs for the login and whoami commands

* Added readme instructions for the update command

* Removed some unused things

* Remove fetchUseProxy for now
2024-01-15 14:45:41 +00:00
Kritik Jiyaviya 3bb82ed9a5 feat: runs filtering by relative timeframe (#832)
* feat: runs filtering by relative timeframe

* improve logic

* Used formatDateTime

* fix: logic of  determineTimeFrame() function

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-15 14:13:20 +00:00
Abhimanyu Yadav ff4ff869ab feat(logger): accepting Error objects as parameters in io.logger.error() (#797)
* feat(logger): accepting Error objects as parameters in io.logger.error()

* Allow errors for any log level

* Create silent-ties-vanish.md

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-15 14:02:41 +00:00
Matt Aitken 1dcee2b338 Changed docs analytics to PostHog EU 2024-01-15 13:51:02 +00:00
Aditya Tripathi 0a798446c8 feat: managed cloud check for search indexing (#825)
* feat: managed cloud check for search indexing

* fix: vice versa on the robots meta tag

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-15 09:35:43 +00:00
Kritik Jiyaviya 209942a63d fix: integrations list scrollbar (#829) 2024-01-15 09:29:42 +00:00
Manthan Mallikarjun 57a9b35870 Fix typo in deployment-automatic docs (#842)
Env var is wrong
2024-01-15 09:28:37 +00:00
Fabian B a16b65f666 docs: fix syntax error (#839) 2024-01-14 16:55:01 +00:00
165 changed files with 7222 additions and 544 deletions
@@ -13,8 +13,15 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
["#22C55E", "#22C55E", "#F59E0B", "#F43F5E", "#F43F5E"]
);
const hasHitLimit = cappedPercentage >= 1;
return (
<div className="rounded border border-slate-900 bg-[#101722] p-2.5">
<div
className={cn(
"rounded border border-slate-900 bg-[#101722] p-2.5",
hasHitLimit && "border-rose-800/60"
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<ArrowUpCircleIcon className="h-5 w-5 text-dimmed" />
@@ -5,6 +5,7 @@ import { formatNumberCompact } from "~/utils/numberFormatter";
import { plansPath } from "~/utils/pathBuilder";
import { LinkButton } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
import { Callout } from "../primitives/Callout";
type UpgradePromptProps = {
organization: MatchedOrganization;
@@ -18,19 +19,25 @@ export function UpgradePrompt({ organization }: UpgradePromptProps) {
}
return (
<div className="flex h-full w-full items-center gap-4 bg-gradient-to-r from-transparent to-indigo-900/50 pr-1.5">
<Paragraph variant="extra-small" className="text-rose-500">
You have exceeded the monthly {formatNumberCompact(currentPlan.usage.runCountCap)} runs
limit
</Paragraph>
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
</div>
<Callout variant="error" className="flex h-full items-center rounded-none px-1 py-0">
<div className="flex items-center justify-between gap-3">
<Paragraph variant="extra-small" className="text-white">
{organization.runsEnabled
? `You have exceeded the monthly ${formatNumberCompact(
currentPlan.usage.runCountCap
)} runs
limit`
: `No runs are executing because you have exceeded the free limit`}
</Paragraph>
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
</div>
</Callout>
);
}
@@ -5,4 +5,12 @@ export const EventListSearchSchema = z.object({
cursor: z.string().optional(),
direction: DirectionSchema.optional(),
environment: FilterableEnvironment.optional(),
from: z
.string()
.transform((value) => parseInt(value))
.optional(),
to: z
.string()
.transform((value) => parseInt(value))
.optional(),
});
@@ -12,14 +12,19 @@ import {
} from "../primitives/Select";
import { EventListSearchSchema } from "./EventStatuses";
import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
import { TimeFrameFilter } from "../runs/TimeFrameFilter";
import { useCallback } from "react";
import { Button } from "../primitives/Buttons";
export function EventsFilters() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environment } = EventListSearchSchema.parse(Object.fromEntries(searchParams.entries()));
const { environment, from, to } = EventListSearchSchema.parse(
Object.fromEntries(searchParams.entries())
);
const handleFilterChange = (filterType: string, value: string | undefined) => {
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
@@ -28,12 +33,38 @@ export function EventsFilters() {
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
};
}, []);
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
if (range.from) {
searchParams.set("from", range.from.toString());
} else {
searchParams.delete("from");
}
if (range.to) {
searchParams.set("to", range.to.toString());
} else {
searchParams.delete("to");
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleEnvironmentChange = (value: FilterableEnvironment | "ALL") => {
handleFilterChange("environment", value === "ALL" ? undefined : value);
};
const clearFilters = useCallback(() => {
searchParams.delete("status");
searchParams.delete("environment");
searchParams.delete("from");
searchParams.delete("to");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
return (
<div className="flex flex-row justify-between gap-x-2">
<SelectGroup>
@@ -62,6 +93,12 @@ export function EventsFilters() {
</SelectContent>
</Select>
</SelectGroup>
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
Clear
</Button>
</div>
);
}
@@ -49,13 +49,13 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
<TableRow key={job.id} className="group">
<TableCell to={path}>
<span className="flex items-center gap-2">
<NamedIcon name={job.event.icon} className="h-8 w-8" />
<NamedIcon name={job.event.icon} className="w-8 h-8" />
<LabelValueStack
label={job.title}
value={
job.dynamic ? (
<span className="flex items-center gap-0.5">
<NamedIcon name="dynamic" className="h-4 w-4" />{" "}
<NamedIcon name="dynamic" className="w-4 h-4" />{" "}
<span className="uppercase">Dynamic:</span> {job.event.title}
</span>
) : (
@@ -75,9 +75,9 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
key={integration.key}
button={
<div className="relative">
<NamedIcon name={integration.icon} className="h-6 w-6" />
<NamedIcon name={integration.icon} className="w-6 h-6" />
{integration.setupStatus === "MISSING_FIELDS" && (
<NamedIcon name="error" className="absolute -left-1 -top-1 h-4 w-4" />
<NamedIcon name="error" className="absolute w-4 h-4 -left-1 -top-1" />
)}
</div>
}
@@ -99,7 +99,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
{job.properties && (
<SimpleTooltip
button={
<div className="flex max-w-[200px] items-start justify-start gap-5 truncate">
<div className="flex max-w-[300px] items-start justify-start gap-5 truncate">
{job.properties.map((property, index) => (
<LabelValueStack
key={index}
@@ -0,0 +1,104 @@
import { Link } from "@remix-run/react";
import { User } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
import { Header3 } from "../primitives/Headers";
import { ArrowLeftIcon, ChevronLeftIcon } from "@heroicons/react/24/solid";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { Feedback } from "../Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { useV3Enabled } from "~/root";
export function AccountSideMenu({ user }: { user: User }) {
const v3Enabled = useV3Enabled();
return (
<div
className={cn(
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-ui-border transition"
)}
>
<div className="flex h-full flex-col">
<div
className={cn("flex items-center justify-between border-b bg-background p-px transition")}
>
<LinkButton
variant="tertiary/medium"
LeadingIcon={ArrowLeftIcon}
to={rootPath()}
fullWidth
textAlignLeft
>
Account
</LinkButton>
</div>
<div className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-6 flex flex-col gap-1 px-1">
<SideMenuHeader title={user.name ?? user.displayName ?? user.email} />
<SideMenuItem
name="Your profile"
icon="account"
iconColor="text-indigo-500"
to={accountPath()}
data-action="account"
/>
</div>
{v3Enabled && (
<div className="mb-1 flex flex-col gap-1 px-1">
<SideMenuHeader title="Security" />
<SideMenuItem
name="Personal Access Tokens"
icon={ShieldCheckIcon}
iconColor="text-emerald-500"
to={personalAccessTokensPath()}
data-action="tokens"
/>
</div>
)}
</div>
<div className="flex flex-col gap-1 border-t border-border p-1">
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Documentation"
icon="docs"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon="star"
to="https://trigger.dev/changelog"
data-action="changelog"
target="_blank"
/>
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon="log"
data-action="help & feedback"
fullWidth
textAlignLeft
>
Help & Feedback
</Button>
}
/>
</div>
</div>
</div>
);
}
@@ -5,15 +5,14 @@ import {
ChartBarIcon,
CursorArrowRaysIcon,
EllipsisHorizontalIcon,
ShieldCheckIcon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { IconExclamationCircle } from "@tabler/icons-react";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { useFeatures } from "~/hooks/useFeatures";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { usePathName } from "~/hooks/usePathName";
import { MatchedProject } from "~/hooks/useProject";
import { User } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
@@ -28,11 +27,12 @@ import {
organizationIntegrationsPath,
organizationPath,
organizationTeamPath,
personalAccessTokensPath,
projectEnvironmentsPath,
projectEventsPath,
projectHttpEndpointsPath,
projectPath,
projectRunsPath,
projectEventsPath,
projectSetupPath,
projectTriggersPath,
} from "~/utils/pathBuilder";
@@ -42,11 +42,10 @@ import { LogoIcon } from "../LogoIcon";
import { StepContentContainer } from "../StepContentContainer";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { Button, LinkButton } from "../primitives/Buttons";
import { Button } from "../primitives/Buttons";
import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { type IconNames } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
@@ -57,7 +56,9 @@ import {
PopoverSectionHeader,
} from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { MenuCount, SideMenuItem } from "./SideMenuItem";
import { SideMenuHeader } from "./SideMenuHeader";
import { useV3Enabled } from "~/root";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<
@@ -380,6 +381,7 @@ function ProjectSelector({
function UserMenu({ user }: { user: SideMenuUser }) {
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
const navigation = useNavigation();
const v3Enabled = useV3Enabled();
useEffect(() => {
setProfileMenuOpen(false);
@@ -417,6 +419,14 @@ function UserMenu({ user }: { user: SideMenuUser }) {
icon={UserProfilePhoto}
leadingIconClassName="text-indigo-500"
/>
{v3Enabled && (
<PopoverMenuItem
to={personalAccessTokensPath()}
title="Personal Access Tokens"
icon={ShieldCheckIcon}
leadingIconClassName="text-emerald-500"
/>
)}
<PopoverMenuItem
to={logoutPath()}
title="Log out"
@@ -429,99 +439,3 @@ function UserMenu({ user }: { user: SideMenuUser }) {
</Popover>
);
}
function SideMenuHeader({ title, children }: { title: string; children: React.ReactNode }) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
return (
<div className="group flex items-center justify-between pl-1.5">
<Paragraph
variant="extra-extra-small/caps"
className="cursor-default truncate text-slate-500"
>
{title}
</Paragraph>
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
</PopoverCustomTrigger>
<PopoverContent
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
</div>
);
}
function SideMenuItem({
icon,
iconColor,
name,
to,
hasWarning,
count,
target,
subItem = false,
}: {
icon?: IconNames | React.ComponentType<any>;
iconColor?: string;
name: string;
to: string;
hasWarning?: string | boolean;
count?: number;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
subItem?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
to={to}
target={target}
className={cn(
"text-bright group-hover:bg-slate-850",
subItem ? "text-dimmed" : "",
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">
{count !== undefined && count > 0 && <MenuCount count={count} />}
{typeof hasWarning === "string" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
{hasWarning}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
)}
</div>
</div>
</LinkButton>
);
}
function MenuCount({ count }: { count: number | string }) {
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
}
@@ -0,0 +1,38 @@
import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
return (
<div className="group flex items-center justify-between pl-1.5">
<Paragraph
variant="extra-extra-small/caps"
className="cursor-default truncate text-slate-500"
>
{title}
</Paragraph>
{children !== undefined ? (
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
</PopoverCustomTrigger>
<PopoverContent
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
) : null}
</div>
);
}
@@ -0,0 +1,73 @@
import { AnchorHTMLAttributes } from "react";
import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
import { LinkButton } from "../primitives/Buttons";
import { IconNames } from "../primitives/NamedIcon";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { Icon } from "../primitives/Icon";
import { IconExclamationCircle } from "@tabler/icons-react";
export function SideMenuItem({
icon,
iconColor,
name,
to,
hasWarning,
count,
target,
subItem = false,
}: {
icon?: IconNames | React.ComponentType<any>;
iconColor?: string;
name: string;
to: string;
hasWarning?: string | boolean;
count?: number;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
subItem?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
to={to}
target={target}
className={cn(
"text-bright group-hover:bg-slate-850",
subItem ? "text-dimmed" : "",
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">
{count !== undefined && count > 0 && <MenuCount count={count} />}
{typeof hasWarning === "string" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
{hasWarning}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
)}
</div>
</div>
</LinkButton>
);
}
export function MenuCount({ count }: { count: number | string }) {
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
}
@@ -3,6 +3,7 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/utils/cn";
import { motion } from "framer-motion";
const ClientTabs = TabsPrimitive.Root;
@@ -48,4 +49,47 @@ const ClientTabsContent = React.forwardRef<
));
ClientTabsContent.displayName = TabsPrimitive.Content.displayName;
export type TabsProps = {
tabs: {
label: string;
value: string;
}[];
currentValue: string;
className?: string;
layoutId: string;
};
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
return (
<TabsPrimitive.List
className={cn(`flex flex-row gap-x-6 border-b border-slate-700`, className)}
>
{tabs.map((tab, index) => {
const isActive = currentValue === tab.value;
return (
<TabsPrimitive.Trigger
key={tab.value}
value={tab.value}
className={cn(`group flex flex-col items-center`, className)}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-indigo-500" : "text-slate-200"
)}
>
{tab.label}
</span>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-slate-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
})}
</TabsPrimitive.List>
);
}
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
@@ -0,0 +1,243 @@
import { CalendarDateTime, createCalendar } from "@internationalized/date";
import { useDateField, useDateSegment } from "@react-aria/datepicker";
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
import { useDateFieldState } from "@react-stately/datepicker";
import { Granularity } from "@react-types/datepicker";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { useLocales } from "./LocaleProvider";
import { Button } from "./Buttons";
type DateFieldProps = {
label?: string;
defaultValue?: Date;
minValue?: Date;
maxValue?: Date;
className?: string;
fieldClassName?: string;
granularity: Granularity;
showGuide?: boolean;
showNowButton?: boolean;
showClearButton?: boolean;
onValueChange?: (value: Date | undefined) => void;
};
export function DateField({
label,
defaultValue,
onValueChange,
minValue,
maxValue,
granularity,
className,
fieldClassName,
showGuide = false,
showNowButton = false,
showClearButton = false,
}: DateFieldProps) {
const [value, setValue] = useState<undefined | CalendarDateTime>(
utcDateToCalendarDate(defaultValue)
);
const state = useDateFieldState({
value: value,
onChange: (value) => {
if (value) {
setValue(value);
onValueChange?.(value.toDate("utc"));
}
},
minValue: utcDateToCalendarDate(minValue),
maxValue: utcDateToCalendarDate(maxValue),
shouldForceLeadingZeros: true,
granularity,
locale: "en-US",
createCalendar: (name: string) => {
return createCalendar(name);
},
});
//if the passed in value changes, we should update the date
useEffect(() => {
if (state.value === undefined && defaultValue === undefined) return;
const calendarDate = utcDateToCalendarDate(defaultValue);
//unchanged
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
return;
}
setValue(calendarDate);
}, [defaultValue]);
const ref = useRef<null | HTMLDivElement>(null);
const { labelProps, fieldProps } = useDateField(
{
label,
},
state,
ref
);
//render if reverse date order
const yearSegment = state.segments.find((s) => s.type === "year")!;
const monthSegment = state.segments.find((s) => s.type === "month")!;
const daySegment = state.segments.find((s) => s.type === "day")!;
const hourSegment = state.segments.find((s) => s.type === "hour")!;
const minuteSegment = state.segments.find((s) => s.type === "minute")!;
const secondSegment = state.segments.find((s) => s.type === "second")!;
const dayPeriodSegment = state.segments.find((s) => s.type === "dayPeriod")!;
return (
<div className={`flex flex-col items-start ${className || ""}`}>
<span {...labelProps} className="mb-1 ml-0.5 text-xs text-slate-300">
{label}
</span>
<div className="flex flex-row items-center gap-1">
<div
{...fieldProps}
ref={ref}
className={cn(
"flex rounded-sm border border-slate-800 bg-midnight-900 p-0.5 px-1.5 transition-colors focus-within:border-slate-500 hover:border-slate-700 focus-within:hover:border-slate-500",
fieldClassName
)}
>
<DateSegment segment={yearSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={monthSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={daySegment} state={state} />
<DateSegment segment={literalSegment(", ")} state={state} />
<DateSegment segment={hourSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={minuteSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={secondSegment} state={state} />
<DateSegment segment={literalSegment(" ")} state={state} />
<DateSegment segment={dayPeriodSegment} state={state} />
</div>
{showNowButton && (
<Button
variant="secondary/small"
onClick={() => {
const now = new Date();
setValue(utcDateToCalendarDate(new Date()));
onValueChange?.(now);
}}
>
Now
</Button>
)}
{showClearButton && (
<Button
variant="secondary/small"
LeadingIcon={"close"}
onClick={() => {
setValue(undefined);
onValueChange?.(undefined);
state.clearSegment("year");
state.clearSegment("month");
state.clearSegment("day");
state.clearSegment("hour");
state.clearSegment("minute");
state.clearSegment("second");
}}
/>
)}
</div>
{showGuide && (
<div className="mt-1 flex px-2">
{state.segments.map((segment, i) => (
<DateSegmentGuide key={i} segment={segment} />
))}
</div>
)}
</div>
);
}
function utcDateToCalendarDate(date?: Date) {
return date
? new CalendarDateTime(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds()
)
: undefined;
}
type DateSegmentProps = {
segment: DateSegment;
state: DateFieldState;
};
function DateSegment({ segment, state }: DateSegmentProps) {
const ref = useRef<null | HTMLDivElement>(null);
const { segmentProps } = useDateSegment(segment, state, ref);
return (
<div
{...segmentProps}
ref={ref}
style={{
...segmentProps.style,
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none focus:bg-indigo-500 focus:text-white ${
!segment.isEditable ? "text-slate-500" : "text-bright"
}`}
>
{/* Always reserve space for the placeholder, to prevent layout shift when editing. */}
<span
aria-hidden="true"
className="block text-center italic text-slate-500 group-focus:text-white"
style={{
visibility: segment.isPlaceholder ? undefined : "hidden",
height: segment.isPlaceholder ? "" : 0,
pointerEvents: "none",
}}
>
{segment.placeholder}
</span>
{segment.isPlaceholder ? "" : segment.text}
</div>
);
}
function literalSegment(text: string): DateSegment {
return {
type: "literal",
text,
isPlaceholder: false,
isEditable: false,
placeholder: "",
};
}
function minWidthForSegment(segment: DateSegment) {
if (segment.type === "literal") {
return undefined;
}
return String(`${segment.maxValue}`).length + "ch";
}
function DateSegmentGuide({ segment }: { segment: DateSegment }) {
return (
<div
style={{
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none ${
!segment.isEditable ? "text-slate-500" : "text-bright"
}`}
>
<span className="block text-center italic text-slate-500">
{segment.type !== "literal" ? segment.placeholder : segment.text}
</span>
</div>
);
}
@@ -2,6 +2,7 @@ import * as React from "react";
import { cn } from "~/utils/cn";
import type { IconNamesOrString } from "./NamedIcon";
import { NamedIcon } from "./NamedIcon";
import { Icon, RenderIcon } from "./Icon";
const variants = {
large: {
@@ -44,7 +45,7 @@ const variants = {
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
variant?: keyof typeof variants;
icon?: IconNamesOrString;
icon?: RenderIcon;
shortcut?: string;
fullWidth?: boolean;
};
@@ -59,7 +60,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<div className={cn("relative", fullWidth ? "w-full" : "max-w-max")}>
{icon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center">
<NamedIcon name={icon} className={cn(iconClassName, "text-dimmed")} />
<Icon icon={icon} className={cn(iconClassName, "text-dimmed")} />
</div>
)}
<input
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
avoidCollisions={true}
className={cn(
"z-50 min-w-max rounded-md border bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-max rounded-md border border-slate-700 bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
style={{
@@ -56,7 +56,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-max overflow-hidden rounded-md bg-popover text-bright shadow-md animate-in fade-in-40",
"relative z-50 min-w-max overflow-hidden rounded-md border border-slate-700 bg-popover text-bright shadow-md animate-in fade-in-40",
position === "popper" && "translate-y-1",
className
)}
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
>
<SelectPrimitive.Viewport
className={cn(
"border border-slate-800 px-1 py-0",
"px-1 py-0",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
@@ -122,10 +122,26 @@ type TableCellProps = TableCellBasicProps & {
to?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
hasAction?: boolean;
isSticky?: boolean;
};
const stickyStyles =
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
({ className, alignment = "left", children, colSpan, to, onClick, hasAction = false }, ref) => {
(
{
className,
alignment = "left",
children,
colSpan,
to,
onClick,
hasAction = false,
isSticky = false,
},
ref
) => {
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
@@ -154,6 +170,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
? "cursor-pointer group-hover:bg-slate-900"
: "px-4 py-3 align-middle",
!to && !onClick && alignmentClassName,
isSticky && stickyStyles,
className
)}
colSpan={colSpan}
@@ -174,9 +191,6 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
}
);
const stickyStyles =
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
export const TableCellChevron = forwardRef<
HTMLTableCellElement,
{
@@ -189,7 +203,8 @@ export const TableCellChevron = forwardRef<
>(({ className, to, children, isSticky, onClick }, ref) => {
return (
<TableCell
className={cn(isSticky && stickyStyles, className)}
className={className}
isSticky={isSticky}
to={to}
onClick={onClick}
ref={ref}
@@ -213,7 +228,8 @@ export const TableCellMenu = forwardRef<
const [isOpen, setIsOpen] = useState(false);
return (
<TableCell
className={cn(isSticky && stickyStyles, className)}
className={className}
isSticky={isSticky}
onClick={onClick}
ref={ref}
alignment="right"
+43 -7
View File
@@ -5,6 +5,7 @@ import {
NoSymbolIcon,
PauseCircleIcon,
XCircleIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
@@ -27,16 +28,19 @@ import {
environmentKeys,
statusKeys,
} from "./RunStatuses";
import { TimeFrameFilter } from "./TimeFrameFilter";
import { Button } from "../primitives/Buttons";
import { useCallback } from "react";
export function RunsFilters() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environment, status } = RunListSearchSchema.parse(
const { environment, status, from, to } = RunListSearchSchema.parse(
Object.fromEntries(searchParams.entries())
);
const handleFilterChange = (filterType: string, value: string | undefined) => {
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
@@ -45,15 +49,41 @@ export function RunsFilters() {
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
};
}, []);
const handleStatusChange = (value: FilterableStatus | "ALL") => {
const handleStatusChange = useCallback((value: FilterableStatus | "ALL") => {
handleFilterChange("status", value === "ALL" ? undefined : value);
};
}, []);
const handleEnvironmentChange = (value: FilterableEnvironment | "ALL") => {
const handleEnvironmentChange = useCallback((value: FilterableEnvironment | "ALL") => {
handleFilterChange("environment", value === "ALL" ? undefined : value);
};
}, []);
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
if (range.from) {
searchParams.set("from", range.from.toString());
} else {
searchParams.delete("from");
}
if (range.to) {
searchParams.set("to", range.to.toString());
} else {
searchParams.delete("to");
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const clearFilters = useCallback(() => {
searchParams.delete("status");
searchParams.delete("environment");
searchParams.delete("from");
searchParams.delete("to");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
return (
<div className="flex flex-row justify-between gap-x-2">
@@ -108,6 +138,12 @@ export function RunsFilters() {
</SelectContent>
</Select>
</SelectGroup>
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
Clear
</Button>
</div>
);
}
@@ -156,6 +156,14 @@ export const RunListSearchSchema = z.object({
direction: DirectionSchema.optional(),
status: FilterableStatus.optional(),
environment: FilterableEnvironment.optional(),
from: z
.string()
.transform((value) => parseInt(value))
.optional(),
to: z
.string()
.transform((value) => parseInt(value))
.optional(),
});
export const filterableStatuses: Record<FilterableStatus, JobRunStatus[]> = {
@@ -0,0 +1,243 @@
import { ChevronDownIcon } from "lucide-react";
import { useCallback, useState } from "react";
import {
Calendar,
CalendarDateTime,
DateValue,
getLocalTimeZone,
today,
} from "@internationalized/date";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import { ClientTabs, ClientTabsContent, ClientTabsWithUnderline } from "../primitives/ClientTabs";
import { formatDateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { DateField } from "../primitives/DateField";
import { useLocales } from "../primitives/LocaleProvider";
import { createCalendar } from "@internationalized/date";
type RunTimeFrameFilterProps = {
from?: number;
to?: number;
onRangeChanged: (range: { from?: number; to?: number }) => void;
};
type Mode = "absolute" | "relative";
export function TimeFrameFilter({ from, to, onRangeChanged }: RunTimeFrameFilterProps) {
const [activeTab, setActiveTab] = useState<Mode>("absolute");
const [isOpen, setIsOpen] = useState(false);
const [relativeTimeSeconds, setRelativeTimeSeconds] = useState<number | undefined>();
const fromDate = from ? new Date(from) : undefined;
const toDate = to ? new Date(to) : undefined;
const relativeTimeFrameChanged = useCallback((value: number) => {
const to = new Date().getTime();
const from = to - value;
onRangeChanged({ from, to });
setRelativeTimeSeconds(value);
}, []);
const absoluteTimeFrameChanged = useCallback(({ from, to }: { from?: Date; to?: Date }) => {
setRelativeTimeSeconds(undefined);
const fromTime = from?.getTime();
const toTime = to?.getTime();
onRangeChanged({ from: fromTime, to: toTime });
}, []);
return (
<Popover onOpenChange={(open) => setIsOpen(open)} open={isOpen} modal>
<PopoverTrigger asChild>
<Button
variant="secondary/small"
className="bg-slate-800 group-hover:bg-tertiary-foreground"
>
<Paragraph variant="extra-small" className="mr-2">
{title(from, to, relativeTimeSeconds)}
</Paragraph>
<ChevronDownIcon className="h-4 w-4 text-bright" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="bg-popover p-2">
<ClientTabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as Mode)}
className="p-1"
>
<ClientTabsWithUnderline
tabs={[
{ label: "Absolute", value: "absolute" },
{ label: "Relative", value: "relative" },
]}
currentValue={activeTab}
layoutId={"time-tabs"}
/>
<ClientTabsContent value={"absolute"}>
<AbsoluteTimeFrame
from={fromDate}
to={toDate}
onValueChange={absoluteTimeFrameChanged}
/>
</ClientTabsContent>
<ClientTabsContent value={"relative"}>
<RelativeTimeFrame
value={relativeTimeSeconds}
onValueChange={relativeTimeFrameChanged}
/>
</ClientTabsContent>
</ClientTabs>
</PopoverContent>
</Popover>
);
}
function title(
from: number | undefined,
to: number | undefined,
relativeTimeSeconds: number | undefined
): string {
if (!from && !to) {
return "All time periods";
}
if (relativeTimeSeconds !== undefined) {
return timeFrameValues.find((t) => t.value === relativeTimeSeconds)?.label ?? "Timeframe";
}
let fromString = from ? formatDateTime(new Date(from), "UTC", ["en-US"], false, true) : undefined;
let toString = to ? formatDateTime(new Date(to), "UTC", ["en-US"], false, true) : undefined;
if (from && !to) {
return `From ${fromString} (UTC)`;
}
if (!from && to) {
return `To ${toString} (UTC)`;
}
return `${fromString} - ${toString} (UTC)`;
}
function RelativeTimeFrame({
value,
onValueChange,
}: {
value?: number;
onValueChange: (value: number) => void;
}) {
return (
<div className="grid grid-cols-3 gap-1 pt-2">
{timeFrameValues.map((timeframe) => (
<Button
key={timeframe.value}
variant={value === timeframe.value ? "primary/small" : "tertiary/small"}
className={cn(
"w-full",
value !== timeframe.value && "border border-slate-700 group-hover:bg-slate-700"
)}
onClick={() => {
onValueChange(timeframe.value);
}}
>
<Paragraph variant="extra-small">{timeframe.label}</Paragraph>
</Button>
))}
</div>
);
}
const timeFrameValues = [
{
label: "5 mins",
value: 5 * 60 * 1000,
},
{
label: "15 mins",
value: 15 * 60 * 1000,
},
{
label: "30 mins",
value: 30 * 60 * 1000,
},
{
label: "1 hour",
value: 60 * 60 * 1000,
},
{
label: "3 hours",
value: 3 * 60 * 60 * 1000,
},
{
label: "6 hours",
value: 6 * 60 * 60 * 1000,
},
{
label: "1 day",
value: 24 * 60 * 60 * 1000,
},
{
label: "3 days",
value: 3 * 24 * 60 * 60 * 1000,
},
{
label: "7 days",
value: 7 * 24 * 60 * 60 * 1000,
},
{
label: "10 days",
value: 10 * 24 * 60 * 60 * 1000,
},
{
label: "14 days",
value: 14 * 24 * 60 * 60 * 1000,
},
{
label: "30 days",
value: 30 * 24 * 60 * 60 * 1000,
},
];
export type RelativeTimeFrameItem = (typeof timeFrameValues)[number];
function AbsoluteTimeFrame({
from,
to,
onValueChange,
}: {
from?: Date;
to?: Date;
onValueChange: (value: { from?: Date; to?: Date }) => void;
}) {
return (
<div className="flex flex-col gap-2 pt-2">
<div className="flex flex-col justify-start gap-2">
<div className="flex flex-row items-center gap-1">
<DateField
label="From (UTC)"
defaultValue={from}
onValueChange={(value) => {
onValueChange({ from: value, to: to });
}}
granularity="second"
showNowButton
showClearButton
/>
</div>
<div className="flex flex-row items-center gap-1">
<DateField
label="To (UTC)"
defaultValue={to}
onValueChange={(value) => {
onValueChange({ from: from, to: value });
}}
granularity="second"
showNowButton
showClearButton
/>
</div>
</div>
</div>
);
}
@@ -22,6 +22,7 @@ const mockOrganization: MatchedOrganization = {
],
hasUnconfiguredIntegrations: false,
memberCount: 1,
runsEnabled: true,
};
export const ProgressBar: Story = {
+3
View File
@@ -67,6 +67,9 @@ const EnvironmentSchema = z.object({
TUNNEL_HOST: z.string().optional(),
TUNNEL_SECRET_KEY: z.string().optional(),
//v3
V3_ENABLED: z.string().default("false"),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
@@ -10,6 +10,8 @@ type EventListOptions = {
filterEnvironment?: FilterableEnvironment;
cursor?: string;
pageSize?: number;
from?: number;
to?: number;
};
const DEFAULT_PAGE_SIZE = 20;
@@ -31,6 +33,8 @@ export class EventListPresenter {
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
from,
to,
}: EventListOptions) {
const directionMultiplier = direction === "forward" ? 1 : -1;
@@ -91,12 +95,19 @@ export class EventListPresenter {
},
where: {
internal: false,
name: {
notIn: ["trigger.scheduled", "dev.trigger.scheduled"],
},
projectId: project.id,
organizationId: organization.id,
environmentId: {
in: environments.map((environment) => environment.id),
},
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
createdAt: {
gte: from ? new Date(from).toISOString() : undefined,
lte: to ? new Date(to).toISOString() : undefined,
},
},
orderBy: [{ id: "desc" }],
//take an extra record to tell if there are more
@@ -94,6 +94,7 @@ export class OrganizationsPresenter {
})),
hasUnconfiguredIntegrations: org._count.integrations > 0,
memberCount: org._count.members,
runsEnabled: org.runsEnabled,
};
});
}
@@ -19,6 +19,8 @@ type RunListOptions = {
filterEnvironment?: FilterableEnvironment;
cursor?: string;
pageSize?: number;
from?: number;
to?: number;
};
const DEFAULT_PAGE_SIZE = 20;
@@ -43,6 +45,8 @@ export class RunListPresenter {
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
from,
to,
}: RunListOptions) {
const filterStatuses = filterStatus ? filterableStatuses[filterStatus] : undefined;
@@ -133,6 +137,10 @@ export class RunListPresenter {
},
status: filterStatuses ? { in: filterStatuses } : undefined,
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
startedAt: {
gte: from ? new Date(from).toISOString() : undefined,
lte: to ? new Date(to).toISOString() : undefined,
},
},
orderBy: [{ id: "desc" }],
//take an extra record to tell if there are more
+21 -1
View File
@@ -1,7 +1,15 @@
import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useMatches,
} from "@remix-run/react";
import { metaV1 } from "@remix-run/v1-meta";
import { TypedMetaFunction, typedjson, useTypedLoaderData } from "remix-typedjson";
import { ExternalScripts } from "remix-utils/external-scripts";
@@ -16,6 +24,7 @@ import { env } from "./env.server";
import { featuresForRequest } from "./features.server";
import { useHighlight } from "./hooks/useHighlight";
import { usePostHog } from "./hooks/usePostHog";
import { useTypedMatchesData } from "./hooks/useTypedMatchData";
import { getUser } from "./services/session.server";
import { appEnvTitleTag } from "./utils";
@@ -28,14 +37,24 @@ export const meta: TypedMetaFunction<typeof loader> = (args) => {
title: `Trigger.dev${appEnvTitleTag(args.data?.appEnv)}`,
charset: "utf-8",
viewport: "width=1024, initial-scale=1",
robots: args.data.features.isManagedCloud ? "index, follow" : "noindex, nofollow",
});
};
export function useV3Enabled() {
const routeMatch = useTypedMatchesData<typeof loader>({
id: "root",
});
return routeMatch?.v3Enabled ?? false;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("cookie"));
const toastMessage = session.get("toastMessage") as ToastMessage;
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
const highlightProjectId = env.HIGHLIGHT_PROJECT_ID;
const v3Enabled = env.V3_ENABLED === "true";
const features = featuresForRequest(request);
return typedjson(
@@ -47,6 +66,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
features,
appEnv: env.APP_ENV,
appOrigin: env.APP_ORIGIN,
v3Enabled,
},
{ headers: { "Set-Cookie": await commitSession(session) } }
);
@@ -96,7 +96,7 @@ export default function Page() {
<div className="flex flex-col gap-5 rounded border border-border p-6">
{hitsRunLimit && (
<Callout
variant={"pricing"}
variant={"error"}
cta={
<LinkButton
variant="primary/small"
@@ -108,7 +108,7 @@ export default function Page() {
</LinkButton>
}
>
<Paragraph variant="small">
<Paragraph variant="small" className="text-white">
You have exceeded the monthly{" "}
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs limit.
Upgrade to a paid plan before{" "}
@@ -65,7 +65,7 @@ export default function Page() {
</Callout>
)}
{hitRunLimit && (
<Callout variant={"pricing"}>
<Callout variant={"error"}>
{`You have exceeded the monthly
${formatNumberCompact(currentPlan!.subscription!.limits.runs!)} runs limit. Upgrade so you
can continue to perform runs.`}
@@ -99,7 +99,7 @@ export default function Integrations() {
</PageHeader>
<PageBody scrollable={false}>
<div className="grid h-full max-w-full grid-cols-[2fr_3fr] gap-4 divide-x divide-slate-900 overflow-hidden">
<div className="grid h-full max-w-full grid-cols-[2fr_3fr] divide-x divide-slate-900 overflow-hidden">
<PossibleIntegrationsList
options={options}
organizationId={organization.id}
@@ -142,7 +142,7 @@ function PossibleIntegrationsList({
return (
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="py-4 pl-4">
<div className="p-4">
<div className="flex items-center justify-between">
<Header2 className="mb-2">Connect an API</Header2>
<Switch
@@ -52,6 +52,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
});
return typedjson({ event, list });
@@ -37,6 +37,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
pageSize: 25,
});
@@ -41,6 +41,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
});
return typedjson({
@@ -40,6 +40,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
direction: searchParams.direction,
cursor: searchParams.cursor,
pageSize: 25,
from: searchParams.from,
to: searchParams.to,
});
return typedjson({
@@ -0,0 +1,193 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData } from "@remix-run/react";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { updateUser } from "~/models/user.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { accountPath } from "~/utils/pathBuilder";
function createSchema(
constraints: {
isEmailUnique?: (email: string) => Promise<boolean>;
} = {}
) {
return z.object({
name: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
email: z
.string()
.email()
.superRefine((email, ctx) => {
if (constraints.isEmailUnique === undefined) {
//client-side validation skips this
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
// Tell zod this is an async validation by returning the promise
return constraints.isEmailUnique(email).then((isUnique) => {
if (isUnique) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email is already being used by a different account",
});
});
}
}),
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
});
}
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const formSchema = createSchema({
isEmailUnique: async (email) => {
const existingUser = await prisma.user.findFirst({
where: {
email,
},
});
if (!existingUser) {
return true;
}
if (existingUser.id === userId) {
return true;
}
return false;
},
});
const submission = await parse(formData, { schema: formSchema, async: true });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const user = await updateUser({
id: userId,
name: submission.value.name,
email: submission.value.email,
marketingEmails: submission.value.marketingEmails,
});
return redirectWithSuccessMessage(
accountPath(),
request,
"Your account profile has been updated."
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export const handle: Handle = {
breadcrumb: (match) => {
return <BreadcrumbLink to={match.pathname} title={"Profile"} />;
},
};
export default function Page() {
const user = useUser();
const lastSubmission = useActionData();
const [form, { name, email, marketingEmails }] = useForm({
id: "account",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: createSchema() });
},
});
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="Your profile" />
</PageTitleRow>
</PageHeader>
<PageBody>
<Form method="post" {...form.props} className="max-w-md">
<InputGroup className="mb-4">
<Label htmlFor={name.id}>Profile picture</Label>
<UserProfilePhoto className="h-24 w-24" />
</InputGroup>
<Fieldset>
<InputGroup>
<Label htmlFor={name.id}>Full name</Label>
<Input
{...conform.input(name, { type: "text" })}
placeholder="Your full name"
defaultValue={user?.name ?? ""}
icon="account"
/>
<Hint>Your teammates will see this</Hint>
<FormError id={name.errorId}>{name.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={email.id}>Email address</Label>
<Input
{...conform.input(email, { type: "text" })}
placeholder="Your email"
defaultValue={user?.email ?? ""}
icon="envelope"
/>
<FormError id={email.errorId}>{email.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive product updates"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
/>
</Fieldset>
</Form>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,93 @@
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { title } from "process";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header1 } from "~/components/primitives/Headers";
import { Icon } from "~/components/primitives/Icon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { logger } from "~/services/logger.server";
import { createPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
import { requireUserId } from "~/services/session.server";
import { rootPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
authorizationCode: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
logger.info("Invalid params", { params });
throw new Response(undefined, {
status: 400,
statusText: "Invalid params",
});
}
try {
const personalAccessToken = await createPersonalAccessTokenFromAuthorizationCode(
parsedParams.data.authorizationCode,
userId
);
return typedjson({
success: true as const,
});
} catch (error) {
if (error instanceof Response) {
throw error;
}
if (error instanceof Error) {
return typedjson({
success: false as const,
error: error.message,
});
}
logger.error(JSON.stringify(error));
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const result = useTypedLoaderData<typeof loader>();
return (
<AppContainer>
<MainCenteredContainer className="max-w-[22rem]">
<div className="flex flex-col items-center space-y-4">
{result.success ? (
<div>
<Header1 className="mb-2 flex items-center gap-1">
<Icon icon={CheckCircleIcon} className="h-6 w-6 text-emerald-500" /> Successfully
authenticated
</Header1>
<Paragraph>Return to your terminal to continue.</Paragraph>
</div>
) : (
<div>
<Header1 className="mb-2">Authentication failed</Header1>
<Callout variant="error" className="my-2">
{result.error}
</Callout>
<Paragraph spacing>
There was a problem authenticating you, please try logging in with your CLI again.
</Paragraph>
</div>
)}
</div>
</MainCenteredContainer>
</AppContainer>
);
}
@@ -0,0 +1,312 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useFetcher } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import {
PageButtons,
PageDescription,
PageHeader,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { redirectWithSuccessMessage } from "~/models/message.server";
import {
CreatedPersonalAccessToken,
ObfuscatedPersonalAccessToken,
createPersonalAccessToken,
getValidPersonalAccessTokens,
revokePersonalAccessToken,
} from "~/services/personalAccessToken.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { personalAccessTokensPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
try {
const personalAccessTokens = await getValidPersonalAccessTokens(userId);
return typedjson({
personalAccessTokens,
});
} catch (error) {
if (error instanceof Response) {
throw error;
}
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
const CreateTokenSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("create"),
tokenName: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
}),
z.object({
action: z.literal("revoke"),
tokenId: z.string(),
}),
]);
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const submission = parse(formData, { schema: CreateTokenSchema });
if (!submission.value) {
return json(submission);
}
switch (submission.value.action) {
case "create": {
try {
const tokenResult = await createPersonalAccessToken({
name: submission.value.tokenName,
userId,
});
return json({ ...submission, payload: { token: tokenResult } });
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}
case "revoke": {
try {
await revokePersonalAccessToken(submission.value.tokenId);
return redirectWithSuccessMessage(
personalAccessTokensPath(),
request,
"Personal Access Token revoked"
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}
default: {
return json({ errors: { body: "Invalid action" } }, { status: 400 });
}
}
};
export const handle: Handle = {
breadcrumb: (match) => {
return <BreadcrumbLink to={match.pathname} title={"Personal Access Tokens"} />;
},
};
export default function Page() {
const { personalAccessTokens } = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="Personal Access Tokens" />
<PageButtons>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small">Create new token</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Create a Personal Access Token</DialogHeader>
<CreatePersonalAccessToken />
</DialogContent>
</Dialog>
</PageButtons>
</PageTitleRow>
<PageDescription>Personal Access Tokens can be used with our CLI and API.</PageDescription>
</PageHeader>
<PageBody>
<div className="flex flex-col gap-3">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Token</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Last accessed</TableHeaderCell>
<TableHeaderCell hiddenLabel>Delete</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{personalAccessTokens.length > 0 ? (
personalAccessTokens.map((personalAccessToken) => {
return (
<TableRow key={personalAccessToken.id} className="group">
<TableCell>{personalAccessToken.name}</TableCell>
<TableCell>{personalAccessToken.obfuscatedToken}</TableCell>
<TableCell>
<DateTime date={personalAccessToken.createdAt} />
</TableCell>
<TableCell>
{personalAccessToken.lastAccessedAt ? (
<DateTime date={personalAccessToken.lastAccessedAt} />
) : (
"Never"
)}
</TableCell>
<TableCell alignment="right">
<RevokePersonalAccessToken token={personalAccessToken} />
</TableCell>
</TableRow>
);
})
) : (
<TableBlankRow colSpan={5}>
<Paragraph variant="small" className="flex items-center justify-center">
You have no Personal Access Tokens (that haven't been revoked).
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
</PageBody>
</PageContainer>
);
}
function CreatePersonalAccessToken() {
const fetcher = useFetcher<typeof action>();
const lastSubmission = fetcher.data as any;
const [form, { tokenName }] = useForm({
id: "create-personal-access-token",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: CreateTokenSchema });
},
});
const token = lastSubmission?.payload?.token
? (lastSubmission?.payload?.token as CreatedPersonalAccessToken)
: undefined;
return (
<div className="max-w-full overflow-x-hidden">
{token ? (
<div className="flex flex-col gap-2 p-2">
<Header2>Successfully generated a new token</Header2>
<Callout variant="success">
Copy this access token and store it in a secure place - you will not be able to see it
again.
</Callout>
<ClipboardField
secure
value={token.token}
variant={"secondary/medium"}
icon={<ShieldExclamationIcon className="h-5 w-5 text-emerald-500" />}
/>
</div>
) : (
<fetcher.Form method="post" {...form.props}>
<input type="hidden" name="action" value="create" />
<Fieldset>
<InputGroup>
<Label htmlFor={tokenName.id}>Name</Label>
<Input
{...conform.input(tokenName, { type: "text" })}
placeholder="The name of your Personal Access Token"
defaultValue=""
icon={ShieldCheckIcon}
autoComplete="off"
data-1p-ignore
/>
<Hint>
This will help you to identify your token. Tokens called "cli" are automatically
generated when you login with our CLI.
</Hint>
<FormError id={tokenName.errorId}>{tokenName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
/>
</Fieldset>
</fetcher.Form>
)}
</div>
);
}
function RevokePersonalAccessToken({ token }: { token: ObfuscatedPersonalAccessToken }) {
const lastSubmission = useActionData();
const [form, { tokenId }] = useForm({
id: "revoke-personal-access-token",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: CreateTokenSchema });
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon="trash-can" className="text-xs" />
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Revoke Personal Access Token</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
Are you sure you want to revoke "{token.name}"? This can't be reversed.
</Paragraph>
<Form method="post" {...form.props}>
<input type="hidden" name="action" value="revoke" />
<input type="hidden" name="tokenId" value={token.id} />
<Button type="submit" variant="danger/medium" fullWidth>
Revoke token
</Button>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
+22 -172
View File
@@ -1,185 +1,35 @@
import { Form, useActionData } from "@remix-run/react";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Outlet } from "@remix-run/react";
import { AppContainer } from "~/components/layout/AppLayout";
import { AccountSideMenu } from "~/components/navigation/AccountSideMenu";
import { Breadcrumb, BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { PageNavigationIndicator } from "~/components/navigation/PageNavigationIndicator";
import { useUser } from "~/hooks/useUser";
import { z } from "zod";
import { ActionFunction, json, redirect } from "@remix-run/server-runtime";
import { requireUserId } from "~/services/session.server";
import { parse } from "@conform-to/zod";
import { accountPath, rootPath } from "~/utils/pathBuilder";
import { conform, useForm } from "@conform-to/react";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import { Checkbox } from "~/components/primitives/Checkbox";
import { updateUser } from "~/models/user.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { prisma } from "~/db.server";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { FormTitle } from "~/components/primitives/FormTitle";
import { Handle } from "~/utils/handle";
function createSchema(
constraints: {
isEmailUnique?: (email: string) => Promise<boolean>;
} = {}
) {
return z.object({
name: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
email: z
.string()
.email()
.superRefine((email, ctx) => {
if (constraints.isEmailUnique === undefined) {
//client-side validation skips this
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
// Tell zod this is an async validation by returning the promise
return constraints.isEmailUnique(email).then((isUnique) => {
if (isUnique) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email is already being used by a different account",
});
});
}
}),
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
});
}
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const formSchema = createSchema({
isEmailUnique: async (email) => {
const existingUser = await prisma.user.findFirst({
where: {
email,
},
});
if (!existingUser) {
return true;
}
if (existingUser.id === userId) {
return true;
}
return false;
},
});
const submission = await parse(formData, { schema: formSchema, async: true });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const user = await updateUser({
id: userId,
name: submission.value.name,
email: submission.value.email,
marketingEmails: submission.value.marketingEmails,
});
return redirectWithSuccessMessage(
accountPath(),
request,
"Your account profile has been updated."
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
export const handle: Handle = {
breadcrumb: (match) => {
return <BreadcrumbLink to={match.pathname} title={"Account"} />;
},
};
export default function Page() {
const user = useUser();
const lastSubmission = useActionData();
const [form, { name, email, marketingEmails }] = useForm({
id: "account",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: createSchema() });
},
});
return (
<AppContainer>
<MainCenteredContainer>
<FormTitle LeadingIcon="user" title="Profile" />
<Form method="post" {...form.props} className="max-w-md">
<InputGroup className="mb-4">
<Label htmlFor={name.id}>Profile picture</Label>
<UserProfilePhoto className="h-24 w-24" />
</InputGroup>
<Fieldset>
<InputGroup>
<Label htmlFor={name.id}>Full name</Label>
<Input
{...conform.input(name, { type: "text" })}
placeholder="Your full name"
defaultValue={user?.name ?? ""}
icon="account"
/>
<Hint>Your teammates will see this</Hint>
<FormError id={name.errorId}>{name.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={email.id}>Email address</Label>
<Input
{...conform.input(email, { type: "text" })}
placeholder="Your email"
defaultValue={user?.email ?? ""}
icon="envelope"
/>
<FormError id={email.errorId}>{email.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive product updates"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
</InputGroup>
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
<AccountSideMenu user={user} />
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
cancelButton={
<LinkButton to={rootPath()} variant={"secondary/small"}>
Cancel
</LinkButton>
}
/>
</Fieldset>
</Form>
</MainCenteredContainer>
<div className="grid grid-rows-[2.25rem_1fr] overflow-hidden">
<div className="flex w-full items-center justify-between border-b border-ui-border">
<Breadcrumb />
<div className="flex h-full items-center gap-4">
<PageNavigationIndicator className="mr-2" />
</div>
</div>
<Outlet />
</div>
</div>
</AppContainer>
);
}
@@ -0,0 +1,40 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { CreateAuthorizationCodeResponse } from "@trigger.dev/core";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { createAuthorizationCode } from "~/services/personalAccessToken.server";
/** Used to create an AuthorizationCode, that can then be used to obtain a Personal Access Token by logging in with the provided URL */
export async function action({ request }: ActionFunctionArgs) {
logger.info("Creating AuthorizationCode", { url: request.url });
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
//there is no authentication on this endpoint, anyone can create an AuthorizationCode.
//they're only used to allow a user to login, when they'll then receive a Personal Access Token
try {
const authorizationCode = await createAuthorizationCode();
const responseJson: CreateAuthorizationCodeResponse = {
authorizationCode: authorizationCode.code,
url: `${env.APP_ORIGIN}/account/authorization-code/${authorizationCode.code}`,
};
return json(responseJson);
} catch (error) {
if (error instanceof Error) {
logger.error("Error creating AuthorizationCode", {
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
@@ -72,7 +72,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
);
if (!run) {
return json({ error: "Job count not be invoked" }, { status: 500 });
return json({ error: "Job could not be invoked" }, { status: 500 });
}
return json({ id: run.id });
+50
View File
@@ -0,0 +1,50 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import {
GetPersonalAccessTokenRequestSchema,
GetPersonalAccessTokenResponse,
} from "@trigger.dev/core";
import { generateErrorMessage } from "zod-error";
import { logger } from "~/services/logger.server";
import { getPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
export async function action({ request }: ActionFunctionArgs) {
logger.info("Getting PersonalAccessToken from AuthorizationCode", { url: request.url });
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
//There is no authentication on this endpoint, anyone can create an AuthorizationCode.
//But only a logged in user can create a PersonalAccessToken, so for a user who can't login to the app this will always fail.
// Now parse the request body
const anyBody = await request.json();
const body = GetPersonalAccessTokenRequestSchema.safeParse(anyBody);
if (!body.success) {
return json({ message: generateErrorMessage(body.error.issues) }, { status: 422 });
}
try {
const personalAccessToken = await getPersonalAccessTokenFromAuthorizationCode(
body.data.authorizationCode
);
const responseJson: GetPersonalAccessTokenResponse = {
token: personalAccessToken.token,
};
return json(responseJson);
} catch (error) {
if (error instanceof Error) {
logger.error("Error getting PersonalAccessToken from AuthorizationCode", {
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { WhoAmIResponse } from "@trigger.dev/core";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
export async function loader({ request }: LoaderFunctionArgs) {
logger.info("whoami v2", { url: request.url });
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const user = await prisma.user.findUnique({
select: {
email: true,
},
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "User not found" }, { status: 404 });
}
const result: WhoAmIResponse = {
userId: authenticationResult.userId,
email: user.email,
};
return json(result);
}
@@ -0,0 +1,322 @@
import { customAlphabet, nanoid } from "nanoid";
import nodeCrypto from "node:crypto";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { PersonalAccessToken } from "@trigger.dev/database";
const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
type CreatePersonalAccessTokenOptions = {
name: string;
userId: string;
};
/** Returns obfuscated access tokens that aren't revoked */
export async function getValidPersonalAccessTokens(userId: string) {
const personalAccessTokens = await prisma.personalAccessToken.findMany({
select: {
id: true,
name: true,
obfuscatedToken: true,
createdAt: true,
lastAccessedAt: true,
},
where: {
userId,
revokedAt: null,
},
});
return personalAccessTokens.map((pat) => ({
id: pat.id,
name: pat.name,
obfuscatedToken: pat.obfuscatedToken,
createdAt: pat.createdAt,
lastAccessedAt: pat.lastAccessedAt,
}));
}
export type ObfuscatedPersonalAccessToken = Awaited<
ReturnType<typeof getValidPersonalAccessTokens>
>[number];
/** Gets a PersonalAccessToken from an Auth Code, this only works within 10 mins of the auth code being created */
export async function getPersonalAccessTokenFromAuthorizationCode(authorizationCode: string) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
select: {
personalAccessToken: true,
},
where: {
code: authorizationCode,
createdAt: {
gte: tenMinutesAgo,
},
},
});
if (!code) {
throw new Error("Invalid authorization code, or code expired");
}
//there's no PersonalAccessToken associated with this code
if (!code.personalAccessToken) {
return {
token: null,
};
}
const decryptedToken = decryptPersonalAccessToken(code.personalAccessToken);
return {
token: {
token: decryptedToken,
obfuscatedToken: code.personalAccessToken.obfuscatedToken,
},
};
}
export async function revokePersonalAccessToken(tokenId: string) {
await prisma.personalAccessToken.update({
where: {
id: tokenId,
},
data: {
revokedAt: new Date(),
},
});
}
type PersonalAccessTokenAuthenticationResult = {
userId: string;
};
const EncryptedSecretValueSchema = z.object({
nonce: z.string(),
ciphertext: z.string(),
tag: z.string(),
});
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export async function authenticateApiRequestWithPersonalAccessToken(
request: Request
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
const token = getPersonalAccessTokenFromRequest(request);
if (!token) {
return;
}
return authenticatePersonalAccessToken(token);
}
function getPersonalAccessTokenFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
return;
}
const personalAccessToken = authorization.data.replace(/^Bearer /, "");
return personalAccessToken;
}
export async function authenticatePersonalAccessToken(
token: string
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
if (!token.startsWith(tokenPrefix)) {
return;
}
const hashedToken = hashToken(token);
const personalAccessToken = await prisma.personalAccessToken.update({
where: {
hashedToken,
revokedAt: null,
},
data: {
lastAccessedAt: new Date(),
},
});
if (!personalAccessToken) {
return;
}
const decryptedToken = decryptPersonalAccessToken(personalAccessToken);
if (decryptedToken !== token) {
logger.error(
`PersonalAccessToken with id: ${personalAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.`
);
return;
}
return {
userId: personalAccessToken.userId,
};
}
export function createAuthorizationCode() {
return prisma.authorizationCode.create({
data: {
code: nanoid(64),
},
});
}
/** Creates a PersonalAccessToken from an Auth Code, and return the token. We only ever return the unencrypted token once. */
export async function createPersonalAccessTokenFromAuthorizationCode(
authorizationCode: string,
userId: string
) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
where: {
code: authorizationCode,
personalAccessTokenId: null,
createdAt: {
gte: tenMinutesAgo,
},
},
});
if (!code) {
throw new Error("Invalid authorization code, code already used, or code expired");
}
const existingCliPersonalAccessToken = await prisma.personalAccessToken.findFirst({
where: {
userId,
name: "cli",
},
});
//we only allow you to have one CLI PAT at a time
if (existingCliPersonalAccessToken) {
await prisma.personalAccessToken.delete({
where: {
id: existingCliPersonalAccessToken.id,
},
});
}
const token = await createPersonalAccessToken({
name: "cli",
userId,
});
await prisma.authorizationCode.update({
where: {
code: authorizationCode,
},
data: {
personalAccessTokenId: token.id,
},
});
return token;
}
/** Created a new PersonalAccessToken, and return the token. We only ever return the unencrypted token once. */
export async function createPersonalAccessToken({
name,
userId,
}: CreatePersonalAccessTokenOptions) {
const token = createToken();
const encryptedToken = encryptToken(token);
const personalAccessToken = await prisma.personalAccessToken.create({
data: {
name,
userId,
encryptedToken,
obfuscatedToken: obfuscateToken(token),
hashedToken: hashToken(token),
},
});
return {
id: personalAccessToken.id,
name,
userId,
token,
obfuscatedToken: personalAccessToken.obfuscatedToken,
};
}
export type CreatedPersonalAccessToken = Awaited<ReturnType<typeof createPersonalAccessToken>>;
const tokenPrefix = "tr_pat_";
/** Creates a PersonalAccessToken that starts with tr_pat_ */
function createToken() {
return `${tokenPrefix}${tokenGenerator()}`;
}
/** Obfuscates all but the first and last 4 characters of the token, so it looks like tr_pat_bhbd•••••••••••••••••••fd4a */
function obfuscateToken(token: string) {
const withoutPrefix = token.replace(tokenPrefix, "");
const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`;
return `${tokenPrefix}${obfuscated}`;
}
function encryptToken(value: string) {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", env.ENCRYPTION_KEY, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
}
function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
const encryptedData = EncryptedSecretValueSchema.safeParse(personalAccessToken.encryptedToken);
if (!encryptedData.success) {
throw new Error(
`Unable to parse encrypted PersonalAccessToken with id: ${personalAccessToken.id}: ${encryptedData.error.message}`
);
}
const decryptedToken = decryptToken(
encryptedData.data.nonce,
encryptedData.data.ciphertext,
encryptedData.data.tag
);
return decryptedToken;
}
function decryptToken(nonce: string, ciphertext: string, tag: string): string {
const decipher = nodeCrypto.createDecipheriv(
"aes-256-gcm",
env.ENCRYPTION_KEY,
Buffer.from(nonce, "hex")
);
decipher.setAuthTag(Buffer.from(tag, "hex"));
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
function hashToken(token: string): string {
const hash = nodeCrypto.createHash("sha256");
hash.update(token);
return hash.digest("hex");
}
@@ -3,6 +3,7 @@ import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { prisma } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
import { logger } from "../logger.server";
export class CreateRunService {
#prismaClient: PrismaClientOrTransaction;
@@ -25,6 +26,11 @@ export class CreateRunService {
},
options: { callbackUrl?: string } = {}
) {
if (!environment.organization.runsEnabled) {
logger.debug("Runs are disabled for this organization", environment);
return;
}
const endpoint = await this.#prismaClient.endpoint.findUniqueOrThrow({
where: {
id: version.endpointId,
@@ -103,11 +103,11 @@ function validateSchedule(schedule: ScheduleMetadata): ScheduleMetadata {
}
function validateInterval(schedule: IntervalMetadata): ScheduleMetadata {
if (schedule.options.seconds < 60) {
if (schedule.options.seconds < 20) {
return {
type: "interval",
options: {
seconds: 60,
seconds: 20,
},
};
}
+4
View File
@@ -82,6 +82,10 @@ export function accountPath() {
return `/account`;
}
export function personalAccessTokensPath() {
return `/account/tokens`;
}
export function invitesPath() {
return `/invites`;
}
+9 -2
View File
@@ -46,6 +46,7 @@
"@heroicons/react": "^2.0.12",
"@highlight-run/node": "^3.1.0",
"@highlight-run/react": "^3.2.0",
"@internationalized/date": "^3.5.1",
"@lezer/highlight": "^1.1.6",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.3",
@@ -57,6 +58,9 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.5",
"@react-aria/datepicker": "^3.9.1",
"@react-stately/datepicker": "^3.9.1",
"@react-types/datepicker": "^3.7.1",
"@remix-run/express": "2.1.0",
"@remix-run/node": "2.1.0",
"@remix-run/react": "2.1.0",
@@ -66,8 +70,8 @@
"@tabler/icons-react": "^2.39.0",
"@tailwindcss/container-queries": "^0.1.1",
"@team-plain/typescript-sdk": "^3.5.0",
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/billing": "^1.0.10",
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-backend": "workspace:*",
"@trigger.dev/database": "workspace:*",
@@ -105,8 +109,10 @@
"prismjs": "^1.29.0",
"random-words": "^2.0.0",
"react": "^18.2.0",
"react-aria": "^3.31.1",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.1",
"react-stately": "^3.29.1",
"react-use": "^17.4.0",
"recharts": "^2.8.0",
"remix-auth": "^3.6.0",
@@ -172,6 +178,7 @@
"@typescript-eslint/eslint-plugin": "^5.59.6",
"@typescript-eslint/parser": "^5.59.6",
"autoprefixer": "^10.4.13",
"datepicker": "link:@types/@react-aria/datepicker",
"esbuild": "^0.15.10",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
@@ -193,4 +200,4 @@
"engines": {
"node": ">=16.0.0"
}
}
}
@@ -34,7 +34,7 @@ You can add a step to the GitHub Action that deploys your app.
```yaml .github/workflows/release.yml
- name: 🚀 Refresh Trigger.dev Jobs
env:
DEPLOY_TEST_HOOK: ${{ secrets.TRIGGER_ENDPOINT_HOOK }}
TRIGGER_ENDPOINT_HOOK: ${{ secrets.TRIGGER_ENDPOINT_HOOK }}
run: |
curl -X POST $TRIGGER_ENDPOINT_HOOK
```
+1 -1
View File
@@ -64,7 +64,7 @@ client.defineJob({
name: "Example Job",
version: "0.1.0",
trigger: eventTrigger({ name: "example.event" }),
enabled: process.env.TRIGGER_JOBS_DISABLED === "true",
enabled: process.env.TRIGGER_JOBS_DISABLED !== "true",
run: async (payload, io, ctx) => {
// your Job code here
},
+16 -7
View File
@@ -26,13 +26,22 @@ Send an email to a recipient with a text payload. [Official Resend Docs](https:/
## Tasks
| Function Name | Description |
| --------------- | -------------------------------- |
| `emails.send` | Send an email |
| `emails.create` | Create an email |
| `emails.get` | Get an email |
| `batch.send` | Send a batch of emails at once |
| `batch.create` | Create a batch of emails at once |
| Function Name | Description |
| ------------------ | -------------------------------- |
| `emails.send` | Send an email |
| `emails.create` | Create an email |
| `emails.get` | Get an email |
| `batch.send` | Send a batch of emails at once |
| `batch.create` | Create a batch of emails at once |
| `audiences.create` | Create an audience |
| `audiences.get` | Get an audience |
| `audiences.remove` | Remove an audience |
| `audiences.list` | List audiences |
| `contacts.create` | Create a contact |
| `contacts.get` | Get a contact |
| `contacts.update` | Update a contact |
| `contacts.remove` | Remove a contact |
| `contacts.list` | List contacts |
## Example
+1 -1
View File
@@ -436,7 +436,7 @@
},
"analytics": {
"posthog": {
"apiKey": "phc_hwYmedO564b3Ik8nhA4Csrb5SueY0EwFJWCbseGwWW"
"apiKey": "phc_9aSDbJCaDUMdZdHxxMPTvcj7A9fsl3mCgM1RBPmPsl7"
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ description: "`intervalTrigger()` is set as a [Job's trigger](/sdk/job) to trigg
Intervals are set with a number of seconds. There are some important considerations:
- The Job will first run the specified number of seconds after it has first connected to an [Environment](/documentation/concepts/environments-endpoints). This will happen when you first [deploy](/documentation/guides/deployment) that Job.
- The minimum interval is 60 seconds (any input less than this it will default to 60).
- The minimum interval is 20 seconds (any input less than this it will default to 20).
- The maximum interval is 2_592_000 seconds (30 days), if you pass more than this it will trigger every 30 days.
If you wish to Run a Job at an exact time or less frequently than once pr day you should use a [cronTrigger()](/sdk/crontrigger) instead.
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "logger"
description: "Used to send log messages to the [Run log](/documentation/guides/viewing-runs)."
description: "Used to send log messages to the [Run log](/docs/documentation/guides/viewing-runs)."
---
There are 5 levels, that you can use to log messages.
+1 -1
View File
@@ -207,7 +207,7 @@ client.defineJob({
}
},
{ name: "On Error" },
onError: (error, task) => {
(error, task) => {
//retry the task in 5 minutes
return {
retryAt: new Date(Date.now() + 5 * 60 * 1000),
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/airtable
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/airtable",
"version": "2.3.11",
"version": "2.3.16",
"description": "Trigger.dev integration for airtable",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"@trigger.dev/sdk": "workspace:^2.3.16",
"airtable": "^0.12.1",
"zod": "3.22.3"
},
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/github
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/github",
"version": "2.3.11",
"version": "2.3.16",
"description": "The official GitHub integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -30,8 +30,8 @@
"@octokit/request-error": "^5.0.1",
"@octokit/webhooks": "^12.0.10",
"octokit": "^3.1.2",
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"@trigger.dev/sdk": "workspace:^2.3.16",
"zod": "3.22.3"
},
"engines": {
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/linear
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/linear",
"version": "2.3.11",
"version": "2.3.16",
"description": "Trigger.dev integration for @linear/sdk",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@linear/sdk": "^8.0.0",
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"@trigger.dev/sdk": "workspace:^2.3.16",
"zod": "3.22.3"
},
"engines": {
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/slack
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/openai",
"version": "2.3.11",
"version": "2.3.16",
"description": "The official OpenAI integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -42,8 +42,8 @@
},
"dependencies": {
"openai": "^4.16.1",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.11"
"@trigger.dev/sdk": "workspace:^2.3.16",
"@trigger.dev/integration-kit": "workspace:^2.3.16"
},
"engines": {
"node": ">=18.0.0"
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/plain
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/plain",
"version": "2.3.11",
"version": "2.3.16",
"description": "The official Plain.com integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,8 +24,8 @@
"build:tsup": "tsup"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"@trigger.dev/sdk": "workspace:^2.3.16",
"@team-plain/typescript-sdk": "^2.7.0"
},
"engines": {
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/replicate
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/replicate",
"version": "2.3.11",
"version": "2.3.16",
"description": "Trigger.dev integration for replicate",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"@trigger.dev/sdk": "workspace:^2.3.16",
"replicate": "^0.18.1",
"zod": "3.22.3"
},
+43
View File
@@ -1,5 +1,48 @@
# @trigger.dev/resend
## 2.3.16
### Patch Changes
- da90ee13: Fix resend integration example comment
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- 4dd6cf18: Add support for the new Resend audience API endpoints
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/resend",
"version": "2.3.11",
"version": "2.3.16",
"description": "The official Resend.com integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,9 +24,9 @@
"build:tsup": "tsup"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.11",
"resend": "^2.0.0"
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"@trigger.dev/sdk": "workspace:^2.3.16",
"resend": "^2.1.0"
},
"engines": {
"node": ">=18.0.0"
+134
View File
@@ -0,0 +1,134 @@
import { IntegrationTaskKey, retry } from "@trigger.dev/sdk";
import type { ResendRunTask } from "./index";
import { Resend } from "resend";
import { handleResendError } from "./utils";
type CreateAudienceResult = NonNullable<Awaited<ReturnType<Resend["audiences"]["create"]>>["data"]>;
type GetAudienceResult = NonNullable<Awaited<ReturnType<Resend["audiences"]["get"]>>["data"]>;
type DeleteAudienceResult = NonNullable<Awaited<ReturnType<Resend["audiences"]["remove"]>>["data"]>;
type ListAudiencesResult = NonNullable<Awaited<ReturnType<Resend["audiences"]["list"]>>["data"]>;
export class Audiences {
constructor(private runTask: ResendRunTask) {}
create(
key: IntegrationTaskKey,
payload: Parameters<Resend["audiences"]["create"]>[0],
options?: Parameters<Resend["audiences"]["create"]>[1]
): Promise<CreateAudienceResult> {
return this.runTask(
key,
async (client, task) => {
const { error, data } = await client.audiences.create(payload, options);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from Resend");
}
return data;
},
{
name: "Create Audience",
params: payload,
properties: [
{
label: "Name",
text: payload.name,
},
],
retry: retry.standardBackoff,
},
handleResendError
);
}
get(key: IntegrationTaskKey, payload: string): Promise<GetAudienceResult> {
return this.runTask(
key,
async (client, task) => {
const { error, data } = await client.audiences.get(payload);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from Resend");
}
return data;
},
{
name: "Get Audience",
params: payload,
properties: [
{
label: "ID",
text: payload,
},
],
retry: retry.standardBackoff,
},
handleResendError
);
}
remove(key: IntegrationTaskKey, payload: string): Promise<DeleteAudienceResult> {
return this.runTask(
key,
async (client, task) => {
const { error, data } = await client.audiences.remove(payload);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from Resend");
}
return data;
},
{
name: "Remove Audience",
params: payload,
properties: [
{
label: "ID",
text: payload,
},
],
retry: retry.standardBackoff,
},
handleResendError
);
}
list(key: IntegrationTaskKey): Promise<ListAudiencesResult> {
return this.runTask(
key,
async (client, task) => {
const { error, data } = await client.audiences.list();
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from Resend");
}
return data;
},
{
name: "List Audiences",
retry: retry.standardBackoff,
},
handleResendError
);
}
}
+188
View File
@@ -0,0 +1,188 @@
import { IntegrationTaskKey, retry } from "@trigger.dev/sdk";
import type { ResendRunTask } from "./index";
import { Resend } from "resend";
import { handleResendError } from "./utils";
type CreateContactResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["create"]>>["data"]>;
type GetContactResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["get"]>>["data"]>;
type UpdateContactResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["update"]>>["data"]>;
type DeleteContactResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["remove"]>>["data"]>;
type ListContactsResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["list"]>>["data"]>;
export class Contacts {
constructor(private runTask: ResendRunTask) {}
create(
key: IntegrationTaskKey,
payload: Parameters<Resend["contacts"]["create"]>[0],
options?: Parameters<Resend["contacts"]["create"]>[1]
): Promise<CreateContactResult> {
return this.runTask(
key,
async (client, task) => {
const { error, data } = await client.contacts.create(payload, options);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from Resend");
}
return data;
},
{
name: "Create Contact",
params: payload,
properties: [
{
label: "Email",
text: payload.email,
},
...(payload.first_name && payload.last_name
? [{ label: "Name", text: payload.first_name + " " + payload.last_name }]
: []),
],
retry: retry.standardBackoff,
},
handleResendError
);
}
get(
key: IntegrationTaskKey,
payload: Parameters<Resend["contacts"]["get"]>[0]
): Promise<GetContactResult> {
return this.runTask(
key,
async (client, task) => {
const { error, data } = await client.contacts.get(payload);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from Resend");
}
return data;
},
{
name: "Get Contact",
params: payload,
properties: [
{
label: "Id",
text: payload.id,
},
],
retry: retry.standardBackoff,
},
handleResendError
);
}
update(
key: IntegrationTaskKey,
payload: Parameters<Resend["contacts"]["update"]>[0]
): Promise<UpdateContactResult> {
return this.runTask(
key,
async (client, task) => {
const { error, data } = await client.contacts.update(payload);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from Resend");
}
return data;
},
{
name: "Update Contact",
params: payload,
properties: [
{
label: "Id",
text: payload.id,
},
],
retry: retry.standardBackoff,
},
handleResendError
);
}
remove(
key: IntegrationTaskKey,
payload: Parameters<Resend["contacts"]["remove"]>[0]
): Promise<DeleteContactResult> {
return this.runTask(
key,
async (client, task) => {
const { error, data } = await client.contacts.remove(payload);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from Resend");
}
return data;
},
{
name: "Remove Contact",
params: payload,
properties: [
{
label: "Id",
text: payload.id,
},
],
retry: retry.standardBackoff,
},
handleResendError
);
}
list(
key: IntegrationTaskKey,
payload: Parameters<Resend["contacts"]["list"]>[0]
): Promise<ListContactsResult> {
return this.runTask(
key,
async (client, task) => {
const { error, data } = await client.contacts.list(payload);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from Resend");
}
return data;
},
{
name: "List Contacts",
params: payload,
properties: [
{
label: "Audience Id",
text: payload.audience_id,
},
],
retry: retry.standardBackoff,
},
handleResendError
);
}
}
+5 -3
View File
@@ -3,9 +3,11 @@ import type { ResendRunTask } from "./index";
import { Resend } from "resend";
import { handleResendError } from "./utils";
type SendEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["send"]>>["data"]>;
type CreateEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["create"]>>["data"]>;
type GetEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["get"]>>["data"]>;
export type SendEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["send"]>>["data"]>;
export type CreateEmailResult = NonNullable<
Awaited<ReturnType<Resend["emails"]["create"]>>["data"]
>;
export type GetEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["get"]>>["data"]>;
export class Emails {
constructor(private runTask: ResendRunTask) {}
+36 -2
View File
@@ -9,8 +9,10 @@ import {
type TriggerIntegration,
} from "@trigger.dev/sdk";
import { Resend as ResendClient } from "resend";
import { Emails } from "./emails";
import { Emails, SendEmailResult } from "./emails";
import { Batch } from "./batch";
import { Contacts } from "./contacts";
import { Audiences } from "./audiences";
type ErrorResponse = {
statusCode: number;
@@ -154,7 +156,39 @@ export class Resend implements TriggerIntegration {
/**
* @deprecated Please use resend.emails.send instead
*/
async sendEmail(...args: Parameters<typeof this.emails.send>) {
async sendEmail(...args: Parameters<typeof this.emails.send>): Promise<SendEmailResult> {
return this.emails.send(...args);
}
/**
* Access the Resend Audiences API
* @example
* ```ts
* const response = await io.resend.audiences.create("📧", {
* name: payload.name
* });
* ```
*/
get audiences() {
return new Audiences(this.runTask.bind(this));
}
/**
* Access the Resend Contacts API
* @example
* ```ts
* const response = await io.resend.contacts.create("📧", {
* email: payload.email,
* first_name: payload.first_name,
* last_name: payload.last_name,
* unsubscribed: payload.unsubscribed,
* audience_id: payload.audience_id
* });
* ```
*/
get contacts() {
return new Contacts(this.runTask.bind(this));
}
}
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/sendgrid
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/sendgrid",
"version": "2.3.11",
"version": "2.3.16",
"description": "Trigger.dev integration for @sendgrid/mail",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@sendgrid/mail": "^7.7.0",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.11"
"@trigger.dev/sdk": "workspace:^2.3.16",
"@trigger.dev/integration-kit": "workspace:^2.3.16"
},
"engines": {
"node": ">=16.8.0"
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/shopify
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/shopify",
"version": "2.3.11",
"version": "2.3.16",
"description": "Trigger.dev integration for @shopify/shopify-api",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@shopify/shopify-api": "^8.0.2",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.16",
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"zod": "3.22.3"
},
"engines": {
+36
View File
@@ -1,5 +1,41 @@
# @trigger.dev/slack
## 2.3.16
### Patch Changes
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
## 2.3.11
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/slack",
"version": "2.3.11",
"version": "2.3.16",
"description": "The official Slack integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,7 +25,7 @@
},
"dependencies": {
"@slack/web-api": "^6.8.1",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.16",
"zod": "3.22.3"
},
"engines": {
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/stripe
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/stripe",
"version": "2.3.11",
"version": "2.3.16",
"description": "Trigger.dev integration for stripe",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -25,8 +25,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"@trigger.dev/sdk": "workspace:^2.3.16",
"stripe": "^12.14.0",
"zod": "3.22.3"
},
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/supabase
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/supabase",
"version": "2.3.11",
"version": "2.3.16",
"description": "Trigger.dev integration for @supabase/supabase-js",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -26,8 +26,8 @@
},
"dependencies": {
"@supabase/supabase-js": "^2.26.0",
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"@trigger.dev/sdk": "workspace:^2.3.16",
"supabase-management-js": "^1.0.0",
"zod": "3.22.3"
},
+41
View File
@@ -1,5 +1,46 @@
# @trigger.dev/typeform
## 2.3.16
### Patch Changes
- @trigger.dev/integration-kit@2.3.16
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
- @trigger.dev/integration-kit@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/integration-kit@2.3.14
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
- @trigger.dev/integration-kit@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
- @trigger.dev/integration-kit@2.3.12
## 2.3.11
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/typeform",
"version": "2.3.11",
"version": "2.3.16",
"description": "The official Typeform integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -24,8 +24,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.3.11",
"@trigger.dev/sdk": "workspace:^2.3.11",
"@trigger.dev/integration-kit": "workspace:^2.3.16",
"@trigger.dev/sdk": "workspace:^2.3.16",
"@typeform/api-client": "^1.8.0",
"zod": "3.22.3"
},
+36
View File
@@ -1,5 +1,41 @@
# @trigger.dev/astro
## 2.3.16
### Patch Changes
- @trigger.dev/sdk@2.3.16
## 2.3.15
### Patch Changes
- Updated dependencies [6c4047cf]
- @trigger.dev/sdk@2.3.15
## 2.3.14
### Patch Changes
- @trigger.dev/sdk@2.3.14
## 2.3.13
### Patch Changes
- Updated dependencies [a93b554f]
- Updated dependencies [0f342cd1]
- @trigger.dev/sdk@2.3.13
## 2.3.12
### Patch Changes
- Updated dependencies [129f023d]
- Updated dependencies [38f5a903]
- Updated dependencies [ff4ff869]
- @trigger.dev/sdk@2.3.12
## 2.3.11
### Patch Changes
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@trigger.dev/astro",
"description": "An Astro-native integration for Trigger.dev background jobs platform",
"version": "2.3.11",
"version": "2.3.16",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
@@ -20,7 +20,7 @@
"build:tsup": "tsup"
},
"peerDependencies": {
"@trigger.dev/sdk": "workspace:^2.3.11"
"@trigger.dev/sdk": "workspace:^2.3.16"
},
"devDependencies": {
"astro": "^3.0.12",
+32
View File
@@ -0,0 +1,32 @@
# trigger.dev
## 1.0.5
### Patch Changes
- Updated dependencies [583da458]
- @trigger.dev/core@2.3.16
## 1.0.4
### Patch Changes
- @trigger.dev/core@2.3.15
## 1.0.3
### Patch Changes
- @trigger.dev/core@2.3.14
## 1.0.2
### Patch Changes
- @trigger.dev/core@2.3.13
## 1.0.1
### Patch Changes
- @trigger.dev/core@2.3.12
+28
View File
@@ -0,0 +1,28 @@
# Running the CLI from source
1. Run the CLI and watch for changes
```sh
cd packages/cli-v3
pnpm run dev
```
2. In the job-catalog folder you can use the CLI
```sh
pnpm i
pnpm exec trigger-v3-cli
```
---
If you want to use it in a new folder, you need to first add it as a dev dependency in package.json:
```json
//...
"devDependencies": {
"trigger.dev": "workspace:*",
//...
}
//...
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Trigger.dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+51
View File
@@ -0,0 +1,51 @@
# Trigger.dev CLI
A CLI that allows you to create, run locally and deploy Trigger.dev background tasks.
Note: this only works with Trigger.dev v3 projects and later. For older projects use the [@trigger.dev/cli](https://www.npmjs.com/package/@trigger.dev/cli) package.
Trigger.dev is an open source platform that makes it easy to create event-driven background tasks directly in your existing project.
## Usage
### Login
Logs that machine into Trigger.dev by creating a new Personal Access Token and storing it on the local machine. Once you're logged in you can perform the other actions below.
```sh
npx trigger.dev@latest login
```
| Option | Short option | Description |
| ----------- | ------------ | -------------------------------------------------------------------- |
| `--api-url` | `-a` | Set the API URL for Trigger.dev, defaults to https://api.trigger.dev |
### Update
Will update all of your @trigger.dev packages in your package.json to the latest version.
```sh
npx trigger.dev@latest update
```
You can pass the path to the folder that your package.json file lives in:
```sh
npx trigger.dev@latest update ./myapp
```
| Option | Short option | Description |
| ------ | ------------ | ---------------------------------------------------------- |
| `--to` | `-t` | The version to update to (ex: 2.1.4), defaults to "latest" |
### Who Am I?
Shows the current user that is logged in.
```sh
npx trigger.dev@latest whoami
```
| Option | Short option | Description |
| ----------- | ------------ | -------------------------------------------------------------------- |
| `--api-url` | `-a` | Set the API URL for Trigger.dev, defaults to https://api.trigger.dev |
+90
View File
@@ -0,0 +1,90 @@
{
"name": "trigger.dev",
"version": "1.0.5",
"description": "A Command-Line Interface for Trigger.dev (v3) projects",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/triggerdotdev/trigger.dev.git",
"directory": "packages/cli-v3"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"typescript",
"trigger.dev",
"workflows",
"orchestration",
"events",
"webhooks",
"integrations",
"apis",
"jobs",
"background jobs",
"nextjs"
],
"files": [
"dist"
],
"type": "module",
"exports": "./dist/index.js",
"bin": {
"trigger-v3-cli": "./dist/index.js"
},
"devDependencies": {
"@trigger.dev/core": "workspace:*",
"@trigger.dev/tsconfig": "workspace:*",
"@types/gradient-string": "^1.1.2",
"@types/mock-fs": "^4.13.1",
"@types/node": "16",
"@types/node-fetch": "^2.6.2",
"@types/ws": "^8.5.3",
"open": "^10.0.3",
"p-retry": "^6.1.0",
"rimraf": "^3.0.2",
"tsup": "^6.5.0",
"type-fest": "^3.6.0",
"typescript": "^4.9.5",
"vitest": "^0.34.4",
"xdg-app-paths": "^8.3.0"
},
"scripts": {
"typecheck": "tsc",
"build": "tsup",
"dev": "tsup --watch",
"clean": "rimraf dist",
"start": "node dist/index.js",
"test": "vitest"
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"@trigger.dev/core": "workspace:*",
"@types/degit": "^2.8.3",
"chalk": "^5.2.0",
"chokidar": "^3.5.3",
"cli-table3": "^0.6.3",
"commander": "^9.4.1",
"degit": "^2.8.4",
"dotenv": "^16.3.1",
"execa": "^7.0.0",
"gradient-string": "^2.0.2",
"liquidjs": "^10.9.2",
"mock-fs": "^5.2.0",
"nanoid": "^4.0.2",
"node-fetch": "^3.3.0",
"npm-check-updates": "^16.12.2",
"posthog-node": "^3.1.1",
"proxy-agent": "^6.3.0",
"simple-git": "^3.19.0",
"update-check": "^1.5.4",
"url": "^0.11.1",
"ws": "^8.11.0",
"zod": "3.22.3"
},
"engines": {
"node": ">=18.0.0"
}
}
+101
View File
@@ -0,0 +1,101 @@
import { z } from "zod";
import {
CreateAuthorizationCodeResponseSchema,
GetPersonalAccessTokenResponseSchema,
WhoAmIResponseSchema,
} from "@trigger.dev/core";
export class ApiClient {
constructor(private readonly apiURL: string) {
this.apiURL = apiURL;
}
async createAuthorizationCode() {
return zodfetch(
CreateAuthorizationCodeResponseSchema,
`${this.apiURL}/api/v1/authorization-code`,
{
method: "POST",
}
);
}
async getPersonalAccessToken(authorizationCode: string) {
return zodfetch(GetPersonalAccessTokenResponseSchema, `${this.apiURL}/api/v1/token`, {
method: "POST",
body: JSON.stringify({
authorizationCode,
}),
});
}
async whoAmI({ accessToken }: { accessToken: string }) {
return zodfetch(WhoAmIResponseSchema, `${this.apiURL}/api/v2/whoami`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
}
}
type ApiResult<TSuccessResult> =
| { success: true; data: TSuccessResult }
| {
success: false;
error: string;
};
async function zodfetch<TResponseBody extends any>(
schema: z.Schema<TResponseBody>,
url: string,
requestInit?: RequestInit
): Promise<ApiResult<TResponseBody>> {
try {
const response = await fetch(url, requestInit);
if ((!requestInit || requestInit.method === "GET") && response.status === 404) {
return {
success: false,
error: `404: ${response.statusText}`,
};
}
if (response.status >= 400 && response.status < 500) {
const body = await response.json();
if (!body.error) {
return { success: false, error: "Something went wrong" };
}
return { success: false, error: body.error };
}
if (response.status !== 200) {
return {
success: false,
error: `Failed to fetch ${url}, got status code ${response.status}`,
};
}
const jsonBody = await response.json();
const parsedResult = schema.safeParse(jsonBody);
if (parsedResult.success) {
return { success: true, data: parsedResult.data };
}
if ("error" in jsonBody) {
return {
success: false,
error: typeof jsonBody.error === "string" ? jsonBody.error : JSON.stringify(jsonBody.error),
};
}
return { success: false, error: parsedResult.error.message };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : JSON.stringify(error),
};
}
}
+94
View File
@@ -0,0 +1,94 @@
import { Command } from "commander";
import { devCommand } from "../commands/dev";
import { updateCommand } from "../commands/update";
import { whoamiCommand } from "../commands/whoami.js";
import { COMMAND_NAME } from "../consts";
import { getVersion } from "../utilities/getVersion";
import { printInitialBanner } from "../utilities/initialBanner";
import { login, loginCommand } from "../commands/login";
import { z } from "zod";
export const program = new Command();
export const ApiUrlOptionsSchema = z.object({
apiUrl: z.string(),
});
program
.name(COMMAND_NAME)
.description("Create, run locally and deploy Trigger.dev background tasks.")
.version(getVersion(), "-v, --version", "Display the version number");
program
.command("login")
.description("Login with Trigger.dev so you can perform authenticated actions")
.option(
"-a, --api-url <value>",
"Override the API URL, defaults to https://api.trigger.dev",
"https://api.trigger.dev"
)
.version(getVersion(), "-v, --version", "Display the version number")
.action(async (options) => {
try {
await printInitialBanner(false);
await loginCommand(options);
//todo login command
} catch (e) {
//todo error reporting
throw e;
}
});
//todo update for the new version
//todo add usage instructions to the README
program
.command("dev")
.description("Run your Trigger.dev tasks locally")
.argument("[path]", "The path to the project", ".")
.option("-p, --port <port>", "Override the local port your server is on")
.option("-H, --hostname <hostname>", "Override the hostname on which the application is served")
.option("-e, --env-file <name>", "Override the name of the env file to load")
.option(
"-i, --client-id <name>",
"The ID of the client to use for this project. Will use the value from the package.json file if not provided."
)
.version(getVersion(), "-v, --version", "Display the version number")
.action(async (path, options) => {
try {
await printInitialBanner();
await devCommand(path, options);
} catch (e) {
//todo error reporting
throw e;
}
});
program
.command("update")
.description(
"Updates all @trigger.dev/* packages to their latest compatible versions or the specified version"
)
.argument("[path]", "The path to the directory that contains the package.json file", ".")
.option("-t, --to <version tag>", "The version to update to (ex: 2.1.4)", "latest")
.action(async (path, options) => {
await printInitialBanner(false);
await updateCommand(path, options);
});
program
.command("whoami")
.description("display the current logged in user and project details")
.option(
"-a, --api-url <value>",
"Override the API URL, defaults to https://cloud.trigger.dev",
"https://cloud.trigger.dev"
)
.version(getVersion(), "-v, --version", "Display the version number")
.action(async (options) => {
try {
await printInitialBanner();
await whoamiCommand(options);
} catch (e) {
throw e;
}
});
+39
View File
@@ -0,0 +1,39 @@
import childProcess from "child_process";
import util from "util";
import { z } from "zod";
import { telemetryClient } from "../telemetry/telemetry";
import { logger } from "../utilities/logger";
import { resolvePath } from "../utilities/parseNameAndPath";
import { RequireKeys } from "../utilities/requiredKeys";
const asyncExecFile = util.promisify(childProcess.execFile);
export const DevCommandOptionsSchema = z.object({
port: z.coerce.number().optional(),
hostname: z.string().optional(),
envFile: z.string().optional(),
clientId: z.string().optional(),
});
export type DevCommandOptions = z.infer<typeof DevCommandOptionsSchema>;
type ResolvedOptions = RequireKeys<DevCommandOptions, "envFile">;
const formattedDate = new Intl.DateTimeFormat("en", {
hour: "numeric",
minute: "numeric",
second: "numeric",
});
export async function devCommand(path: string, anyOptions: any) {
telemetryClient.dev.started(path, anyOptions);
const result = DevCommandOptionsSchema.safeParse(anyOptions);
if (!result.success) {
logger.error(result.error.message);
return;
}
const options = result.data;
const resolvedPath = resolvePath(path);
}
+136
View File
@@ -0,0 +1,136 @@
import { intro, log, outro, select, spinner } from "@clack/prompts";
import open from "open";
import pRetry, { AbortError } from "p-retry";
import { ApiClient } from "../apiClient";
import { ApiUrlOptionsSchema } from "../cli";
import { chalkLink } from "../utilities/colors";
import { readAuthConfigFile, writeAuthConfigFile } from "../utilities/configFiles";
import { logger } from "../utilities/logger";
import { whoAmI } from "./whoami";
export async function loginCommand(options: any) {
const result = ApiUrlOptionsSchema.safeParse(options);
if (!result.success) {
logger.error(result.error.message);
return;
}
return login(result.data.apiUrl);
}
export type LoginResult =
| {
success: true;
accessToken: string;
}
| {
success: false;
error: string;
};
export async function login(apiUrl: string): Promise<LoginResult> {
const apiClient = new ApiClient(apiUrl);
intro("Logging in to Trigger.dev");
const existingAccessToken = readAuthConfigFile()?.accessToken;
if (existingAccessToken) {
const whoAmiI = await whoAmI(apiUrl);
const continueOption = await select({
message: "You are already logged in.",
options: [
{
value: false,
label: "Exit",
},
{
value: true,
label: "Login with a different account",
},
],
initialValue: false,
});
if (continueOption !== true) {
outro("Already logged in");
return {
success: true,
accessToken: existingAccessToken,
};
}
}
//generate authorization code
const createAuthCodeSpinner = spinner();
createAuthCodeSpinner.start("Creating authorition code");
const authorizationCodeResult = await apiClient.createAuthorizationCode();
if (!authorizationCodeResult.success) {
createAuthCodeSpinner.stop(
`Failed to create authorization code\n${authorizationCodeResult.error}`
);
return {
success: false,
error: authorizationCodeResult.error,
};
}
createAuthCodeSpinner.stop("Created authorization code");
//Link the user to the authorization code
log.step(
`Please visit the following URL to login:\n${chalkLink(authorizationCodeResult.data.url)}`
);
await open(authorizationCodeResult.data.url);
//poll for personal access token (we need to poll for it)
const getPersonalAccessTokenSpinner = spinner();
getPersonalAccessTokenSpinner.start("Waiting for you to login");
try {
const indexResult = await pRetry(
() => getPersonalAccessToken(apiClient, authorizationCodeResult.data.authorizationCode),
{
//this means we're polling, same distance between each attempt
factor: 1,
retries: 60,
minTimeout: 1000,
}
);
getPersonalAccessTokenSpinner.stop(`Logged in with token ${indexResult.obfuscatedToken}`);
writeAuthConfigFile({ accessToken: indexResult.token });
outro("Logged in successfully");
return {
success: true,
accessToken: indexResult.token,
};
} catch (e) {
getPersonalAccessTokenSpinner.stop(`Failed to get access token`);
if (e instanceof AbortError) {
log.error(e.message);
}
return {
success: false,
error: e instanceof Error ? e.message : JSON.stringify(e),
};
}
}
async function getPersonalAccessToken(apiClient: ApiClient, authorizationCode: string) {
const token = await apiClient.getPersonalAccessToken(authorizationCode);
if (!token.success) {
throw new AbortError(token.error);
}
if (!token.data.token) {
throw new Error("No token found yet");
}
return {
token: token.data.token.token,
obfuscatedToken: token.data.token.obfuscatedToken,
};
}
+148
View File
@@ -0,0 +1,148 @@
import { spinner, confirm } from "@clack/prompts";
import path from "path";
import { run, RunOptions } from "npm-check-updates";
import { installDependencies } from "../utilities/installDependencies";
import { readJSONFileSync, writeJSONFile } from "../utilities/fileSystem.js";
import { logger } from "../utilities/logger.js";
import { z } from "zod";
import { chalkError, chalkSuccess } from "../utilities/colors";
export const UpdateCommandOptionsSchema = z.object({
to: z.string().optional(),
});
export type UpdateCommandOptions = z.infer<typeof UpdateCommandOptionsSchema>;
type NcuRunOptionTarget = "latest" | `@${string}`;
export async function updateCommand(projectPath: string, anyOptions: any) {
const loadingSpinner = spinner();
loadingSpinner.start("Checking settings");
const parseRes = UpdateCommandOptionsSchema.safeParse(anyOptions);
if (!parseRes.success) {
loadingSpinner.stop(chalkError(parseRes.error.message));
return;
}
const options = parseRes.data;
const triggerDevPackage = "@trigger.dev";
const packageJSONPath = path.join(projectPath, "package.json");
const packageData = readJSONFileSync(packageJSONPath);
if (!packageData) {
loadingSpinner.stop(chalkError("Couldn't load package.json"));
return;
}
loadingSpinner.message("Checking for updates");
const packageMaps: { [k: string]: { type: string; version: string } } = {};
const packageDependencies = packageData.dependencies || {};
const packageDevDependencies = packageData.devDependencies || {};
Object.keys(packageDependencies).forEach((i) => {
packageMaps[i] = { type: "dependencies", version: packageDependencies[i] };
});
Object.keys(packageDevDependencies).forEach((i) => {
packageMaps[i] = {
type: "devDependencies",
version: packageDevDependencies[i],
};
});
const targetVersion = getTargetVersion(options.to);
// Use npm-check-updates to get updated dependency versions
const ncuOptions: RunOptions = {
packageData,
upgrade: true,
jsonUpgraded: true,
target: targetVersion,
};
// Can either give a json like package.json or just with deps and their new versions
const updatedDependencies: { [k: string]: any } | void = await run(ncuOptions);
if (!updatedDependencies) {
loadingSpinner.stop(chalkError("Couldn't update dependencies"));
return;
}
const ifUpdatedDependenciesIsPackageJSON =
updatedDependencies.hasOwnProperty("dependencies") ||
updatedDependencies.hasOwnProperty("devDependencies");
const dependencies = updatedDependencies.dependencies || {};
const devDependencies = updatedDependencies.devDependencies || {};
const allDependencies = ifUpdatedDependenciesIsPackageJSON
? Object.keys({ ...dependencies, ...devDependencies })
: Object.keys(updatedDependencies);
const triggerPackages = allDependencies.filter((pkg) => pkg.startsWith(triggerDevPackage));
// If there are no @trigger.dev packages
if (triggerPackages.length === 0) {
loadingSpinner.stop(chalkSuccess(`All @trigger.dev/* packages are already up to date.`));
return;
}
// Filter the packages with null and what don't match what
// they are installed with so that they can be updated
const packagesToUpdate = triggerPackages.filter((pkg: string) => updatedDependencies[pkg]);
// If no packages require any updation
if (packagesToUpdate.length === 0) {
loadingSpinner.stop(chalkSuccess(`All @trigger.dev/* packages are already up to date.`));
return;
}
let applyUpdates = targetVersion !== "latest";
if (targetVersion === "latest") {
applyUpdates = await hasUserConfirmed(packagesToUpdate, packageMaps, updatedDependencies);
}
if (applyUpdates) {
const newPackageJSON = packageData;
packagesToUpdate.forEach((packageName) => {
const tmp = packageMaps[packageName];
if (tmp) {
newPackageJSON[tmp.type][packageName] = updatedDependencies[packageName];
}
});
await writeJSONFile(packageJSONPath, newPackageJSON);
await installDependencies(projectPath);
}
}
// expects a version number, or latest.
// if version number is specified, prepend it with '@' for ncu.
function getTargetVersion(toVersion?: string): NcuRunOptionTarget {
if (!toVersion) {
return "latest";
}
return toVersion === "latest" ? "latest" : `@${toVersion}`;
}
async function hasUserConfirmed(
packagesToUpdate: string[],
packageMaps: { [x: string]: { type: string; version: string } },
updatedDependencies: { [x: string]: any }
): Promise<boolean> {
// Inform the user of the dependencies that can be updated
console.log("\nNewer versions found for the following packages:");
console.table(
packagesToUpdate.map((i) => ({
name: i,
old: packageMaps[i]?.version,
new: updatedDependencies[i],
}))
);
// Ask the user if they want to update the dependencies
const shouldContinue = await confirm({
message: "Do you want to update these packages in package.json and re-install dependencies?",
});
return shouldContinue as boolean;
}
+81
View File
@@ -0,0 +1,81 @@
import { note, spinner } from "@clack/prompts";
import { ApiUrlOptionsSchema } from "../cli";
import { logger } from "../utilities/logger";
import { resolvePath } from "../utilities/parseNameAndPath";
import { readAuthConfigFile } from "../utilities/configFiles";
import { login } from "./login";
import { ApiClient } from "../apiClient";
type WhoAmIResult =
| {
success: true;
data: {
userId: string;
email: string;
};
}
| {
success: false;
error: string;
};
export async function whoamiCommand(options: any): Promise<WhoAmIResult> {
const result = ApiUrlOptionsSchema.safeParse(options);
if (!result.success) {
logger.error(result.error.message);
return {
success: false,
error: result.error.message,
};
}
return whoAmI(result.data.apiUrl);
}
export async function whoAmI(apiUrl: string): Promise<WhoAmIResult> {
const loadingSpinner = spinner();
loadingSpinner.start("Checking your account details");
if (!readAuthConfigFile()?.accessToken) {
loadingSpinner.stop("You must login.");
const loginResult = await login(apiUrl);
if (!loginResult.success) {
logger.error(loginResult.error);
return {
success: false,
error: loginResult.error,
};
}
}
const accessToken = readAuthConfigFile()?.accessToken;
if (!accessToken) {
logger.error("No access token after login… this should never happen");
return {
success: false,
error: "No access token after login… this should never happen",
};
}
const apiClient = new ApiClient(apiUrl);
const userData = await apiClient.whoAmI({ accessToken });
if (!userData.success) {
loadingSpinner.stop("Error getting your account details");
logger.error(userData.error);
return {
success: false,
error: userData.error,
};
}
loadingSpinner.stop("Retrieved your account details");
note(
`User ID: ${userData.data.userId}
Email: ${userData.data.email}`,
"Account details"
);
return userData;
}
+12
View File
@@ -0,0 +1,12 @@
import path from "path";
import { fileURLToPath } from "url";
// With the move to TSUP as a build tool, this keeps path routes in other files (installers, loaders, etc) in check more easily.
// Path is in relation to a single index.js file inside ./dist
const __filename = fileURLToPath(import.meta.url);
const distPath = path.dirname(__filename);
export const PKG_ROOT = path.join(distPath, "../");
export const COMMAND_NAME = "trigger.dev";
export const CLOUD_WEB_URL = "https://cloud.trigger.dev";
export const CLOUD_API_URL = "https://api.trigger.dev";
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env node
import { program } from "./cli/index";
import { logger } from "./utilities/logger";
const main = async () => {
await program.parseAsync();
};
main().catch((err) => {
if (err instanceof Error) {
logger.error(err);
} else {
logger.error("An unknown error has occurred. Please open an issue on github with the below:");
logger.error(err);
}
process.exit(1);
});
@@ -0,0 +1,51 @@
import { PostHog } from "posthog-node";
import { nanoid } from "nanoid";
import { getVersion } from "../utilities/getVersion";
const postHogApiKey = "phc_9aSDbJCaDUMdZdHxxMPTvcj7A9fsl3mCgM1RBPmPsl7";
export class TelemetryClient {
#client: PostHog;
#sessionId: string;
#version: string;
constructor() {
this.#client = new PostHog(postHogApiKey, {
host: "https://eu.posthog.com",
flushAt: 1,
});
this.#sessionId = `cli-${nanoid()}`;
this.#version = getVersion();
}
identify(organizationId: string, projectId: string, userId?: string) {
if (userId) {
this.#client.alias({
distinctId: userId,
alias: this.#sessionId,
});
}
this.#client.groupIdentify({
groupType: "organization",
groupKey: organizationId,
});
this.#client.groupIdentify({
groupType: "project",
groupKey: projectId,
});
}
dev = {
started: (path: string, options: Record<string, string | number | boolean>) => {
this.#client.capture({
distinctId: this.#sessionId,
event: "cli_dev_started",
properties: { ...options, path },
});
},
};
}
export const telemetryClient = new TelemetryClient();
+32
View File
@@ -0,0 +1,32 @@
import chalk from "chalk";
export const green = "#4FFF54";
export const purple = "#735BF3";
export function chalkGreen(text: string) {
return chalk.hex(green)(text);
}
export function chalkPurple(text: string) {
return chalk.hex(purple)(text);
}
export function chalkGrey(text: string) {
return chalk.hex("#666")(text);
}
export function chalkError(text: string) {
return chalk.red(text);
}
export function chalkSuccess(text: string) {
return chalk.green(text);
}
export function chalkLink(text: string) {
return chalk.underline.blue(text);
}
export function logo() {
return `${chalk.hex(green).bold("Trigger")}${chalk.hex(purple).bold(".dev")}`;
}
@@ -0,0 +1,49 @@
import fs, { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import xdgAppPaths from "xdg-app-paths";
import { z } from "zod";
import { isDirectory, pathExists, readJSONFileSync } from "./fileSystem";
function getGlobalConfigFolderPath() {
const configDir = xdgAppPaths(".trigger").config();
const legacyConfigDir = path.join(os.homedir(), ".trigger"); // Legacy config in user's home directory
// Check for the .trigger directory in root, if it is not there then use the XDG compliant path.
if (isDirectory(legacyConfigDir)) {
return legacyConfigDir;
} else {
return configDir;
}
}
//auth config file
export const UserAuthConfigSchema = z.object({
accessToken: z.string().optional(),
});
export type UserAuthConfig = z.infer<typeof UserAuthConfigSchema>;
function getAuthConfigFilePath() {
return path.join(getGlobalConfigFolderPath(), "config", "default.json");
}
export function writeAuthConfigFile(config: UserAuthConfig) {
const authConfigFilePath = getAuthConfigFilePath();
mkdirSync(path.dirname(authConfigFilePath), {
recursive: true,
});
writeFileSync(path.join(authConfigFilePath), JSON.stringify(config), {
encoding: "utf-8",
});
}
export function readAuthConfigFile(): UserAuthConfig | undefined {
const authConfigFilePath = getAuthConfigFilePath();
if (!pathExists(authConfigFilePath)) {
return;
}
const json = readJSONFileSync(authConfigFilePath);
const parsed = UserAuthConfigSchema.parse(json);
return parsed;
}
@@ -0,0 +1,69 @@
import fsSync from "fs";
import fsModule, { writeFile } from "fs/promises";
import fs from "node:fs";
import pathModule from "path";
// Creates a file at the given path, if the directory doesn't exist it will be created
export async function createFile(path: string, contents: string): Promise<string> {
await fsModule.mkdir(pathModule.dirname(path), { recursive: true });
await fsModule.writeFile(path, contents);
return path;
}
export function isDirectory(configPath: string) {
try {
return fs.statSync(configPath).isDirectory();
} catch (error) {
// ignore error
return false;
}
}
export async function pathExists(path: string): Promise<boolean> {
try {
await fsModule.access(path);
return true;
} catch (err) {
return false;
}
}
export async function someFileExists(directory: string, filenames: string[]): Promise<boolean> {
for (let index = 0; index < filenames.length; index++) {
const filename = filenames[index];
if (!filename) continue;
const path = pathModule.join(directory, filename);
if (await pathExists(path)) {
return true;
}
}
return false;
}
export async function removeFile(path: string) {
await fsModule.unlink(path);
}
export async function readFile(path: string) {
return await fsModule.readFile(path, "utf8");
}
export async function readJSONFile(path: string) {
const fileContents = await fsModule.readFile(path, "utf8");
return JSON.parse(fileContents);
}
export async function writeJSONFile(path: string, json: any) {
await writeFile(path, JSON.stringify(json, null, 2));
}
export function readJSONFileSync(path: string) {
const fileContents = fsSync.readFileSync(path, "utf8");
return JSON.parse(fileContents);
}

Some files were not shown because too many files have changed in this diff Show More