feat(webapp): Vercel integration (#2994)
Vercel integration Desc + Vid coming soon For human reviewer: - check the db schema - check if posthog user attribution call is correct (telemetry.server.ts & `referralSource`) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2994" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add Vercel integration support to API schemas: `commitSHA` and `integrationDeployments` on deployment responses, and `source` field for environment variable imports.
|
||||
Vendored
+2
-1
@@ -7,5 +7,6 @@
|
||||
"packages/cli-v3/e2e": true
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"typescript.experimental.useTsgo": false
|
||||
"typescript.experimental.useTsgo": true,
|
||||
"chat.agent.maxRequests": 10000
|
||||
}
|
||||
|
||||
@@ -32,8 +32,6 @@ export function OctoKitty({ className }: { className?: string }) {
|
||||
baseProfile="tiny"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 2350 2314.8"
|
||||
xmlSpace="preserve"
|
||||
fill="currentColor"
|
||||
|
||||
@@ -10,11 +10,14 @@ import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { CheckboxWithLabel } from "../primitives/Checkbox";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
type ModalProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
hasVercelIntegration: boolean;
|
||||
isDevelopment: boolean;
|
||||
};
|
||||
|
||||
type ModalContentProps = ModalProps & {
|
||||
@@ -22,7 +25,12 @@ type ModalContentProps = ModalProps & {
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
export function RegenerateApiKeyModal({
|
||||
id,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
}: ModalProps) {
|
||||
const randomWord = generateTwoRandomWords();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
@@ -37,6 +45,8 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
<RegenerateApiKeyModalContent
|
||||
id={id}
|
||||
title={title}
|
||||
hasVercelIntegration={hasVercelIntegration}
|
||||
isDevelopment={isDevelopment}
|
||||
randomWord={randomWord}
|
||||
closeModal={() => setOpen(false)}
|
||||
/>
|
||||
@@ -45,7 +55,14 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => {
|
||||
const RegenerateApiKeyModalContent = ({
|
||||
id,
|
||||
randomWord,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
closeModal,
|
||||
}: ModalContentProps) => {
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const fetcher = useFetcher();
|
||||
const isSubmitting = fetcher.state === "submitting";
|
||||
@@ -83,6 +100,15 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
{hasVercelIntegration && !isDevelopment && (
|
||||
<CheckboxWithLabel
|
||||
name="syncToVercel"
|
||||
variant="simple/small"
|
||||
label="Also update TRIGGER_SECRET_KEY in Vercel"
|
||||
defaultChecked={true}
|
||||
value="on"
|
||||
/>
|
||||
)}
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { envSlugToType, type EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
type BuildSettingsFieldsProps = {
|
||||
availableEnvSlugs: EnvSlug[];
|
||||
pullEnvVarsBeforeBuild: EnvSlug[];
|
||||
onPullEnvVarsChange: (slugs: EnvSlug[]) => void;
|
||||
discoverEnvVars: EnvSlug[];
|
||||
onDiscoverEnvVarsChange: (slugs: EnvSlug[]) => void;
|
||||
atomicBuilds: EnvSlug[];
|
||||
onAtomicBuildsChange: (slugs: EnvSlug[]) => void;
|
||||
envVarsConfigLink?: string;
|
||||
};
|
||||
|
||||
export function BuildSettingsFields({
|
||||
availableEnvSlugs,
|
||||
pullEnvVarsBeforeBuild,
|
||||
onPullEnvVarsChange,
|
||||
discoverEnvVars,
|
||||
onDiscoverEnvVarsChange,
|
||||
atomicBuilds,
|
||||
onAtomicBuildsChange,
|
||||
envVarsConfigLink,
|
||||
}: BuildSettingsFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Pull env vars before build */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Pull env vars before build</Label>
|
||||
<Hint>
|
||||
Select which environments should pull environment variables from Vercel before each
|
||||
build.{" "}
|
||||
{envVarsConfigLink && (
|
||||
<>
|
||||
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Hint>
|
||||
</div>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
return (
|
||||
<div key={slug} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
{environmentFullTitle({ type: envType })}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={pullEnvVarsBeforeBuild.includes(slug)}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(
|
||||
checked
|
||||
? [...pullEnvVarsBeforeBuild, slug]
|
||||
: pullEnvVarsBeforeBuild.filter((s) => s !== slug)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discover new env vars */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Discover new env vars</Label>
|
||||
<Hint>
|
||||
Select which environments should automatically discover and create new environment
|
||||
variables from Vercel during builds.
|
||||
</Hint>
|
||||
</div>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every(
|
||||
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
|
||||
) &&
|
||||
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
|
||||
}
|
||||
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
: []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
|
||||
return (
|
||||
<div
|
||||
key={slug}
|
||||
className={`flex items-center justify-between ${isPullDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
{environmentFullTitle({ type: envType })}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={discoverEnvVars.includes(slug)}
|
||||
disabled={isPullDisabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? [...discoverEnvVars, slug]
|
||||
: discoverEnvVars.filter((s) => s !== slug)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Atomic deployments */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Atomic deployments</Label>
|
||||
<Hint>
|
||||
When enabled, production deployments wait for Vercel deployment to complete before
|
||||
promoting the Trigger.dev deployment.
|
||||
</Hint>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={atomicBuilds.includes("prod")}
|
||||
onCheckedChange={(checked) => {
|
||||
onAtomicBuildsChange(checked ? ["prod"] : []);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function VercelLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 76 65"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import {
|
||||
ChartBarIcon,
|
||||
Cog8ToothIcon,
|
||||
CreditCardIcon,
|
||||
PuzzlePieceIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
@@ -12,6 +13,7 @@ import { cn } from "~/utils/cn";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
organizationVercelIntegrationPath,
|
||||
rootPath,
|
||||
v3BillingAlertsPath,
|
||||
v3BillingPath,
|
||||
@@ -113,6 +115,13 @@ export function OrganizationSettingsSideMenu({
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="settings"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon={PuzzlePieceIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={organizationVercelIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="App version" />
|
||||
|
||||
@@ -425,6 +425,11 @@ const EnvironmentSchema = z
|
||||
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
|
||||
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
/** Vercel integration OAuth credentials */
|
||||
VERCEL_INTEGRATION_CLIENT_ID: z.string().optional(),
|
||||
VERCEL_INTEGRATION_CLIENT_SECRET: z.string().optional(),
|
||||
VERCEL_INTEGRATION_APP_SLUG: z.string().optional(),
|
||||
|
||||
/** These enable the alerts feature in v3 */
|
||||
ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||||
ALERT_FROM_EMAIL: z.string().optional(),
|
||||
|
||||
@@ -47,6 +47,13 @@ export type AuthenticatableIntegration = OrganizationIntegration & {
|
||||
tokenReference: SecretReference;
|
||||
};
|
||||
|
||||
export function isIntegrationForService<TService extends IntegrationService>(
|
||||
integration: AuthenticatableIntegration,
|
||||
service: TService
|
||||
): integration is OrganizationIntegrationForService<TService> {
|
||||
return (integration.service satisfies IntegrationService) === service;
|
||||
}
|
||||
|
||||
export class OrgIntegrationRepository {
|
||||
static async getAuthenticatedClientForIntegration<TService extends IntegrationService>(
|
||||
integration: OrganizationIntegrationForService<TService>,
|
||||
@@ -89,6 +96,23 @@ export class OrgIntegrationRepository {
|
||||
static isSlackSupported =
|
||||
!!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET;
|
||||
|
||||
static isVercelSupported =
|
||||
!!env.VERCEL_INTEGRATION_CLIENT_ID && !!env.VERCEL_INTEGRATION_CLIENT_SECRET && !!env.VERCEL_INTEGRATION_APP_SLUG;
|
||||
|
||||
/**
|
||||
* Generate the URL to install the Vercel integration.
|
||||
* Users are redirected to Vercel's marketplace to complete the installation.
|
||||
*
|
||||
* @param state - Base64-encoded state containing org/project info for the callback
|
||||
*/
|
||||
static vercelInstallUrl(state: string): string {
|
||||
// The user goes to Vercel's marketplace to install the integration
|
||||
// After installation, Vercel redirects to our callback with the authorization code
|
||||
const redirectUri = encodeURIComponent(`${env.APP_ORIGIN}/vercel/callback`);
|
||||
const encodedState = encodeURIComponent(state);
|
||||
return `https://vercel.com/integrations/${env.VERCEL_INTEGRATION_APP_SLUG}/new?state=${encodedState}&redirect_uri=${redirectUri}`;
|
||||
}
|
||||
|
||||
static slackAuthorizationUrl(
|
||||
state: string,
|
||||
scopes: string[] = [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,11 @@ export class ApiKeysPresenter {
|
||||
apiKey: true,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
project: {
|
||||
@@ -64,11 +69,22 @@ export class ApiKeysPresenter {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const vercelIntegration =
|
||||
await this.#prismaClient.organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId: environment.project.id,
|
||||
deletedAt: null,
|
||||
organizationIntegration: { service: "VERCEL", deletedAt: null },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
environment: {
|
||||
...environment,
|
||||
apiKey: environment?.parentEnvironment?.apiKey ?? environment?.apiKey,
|
||||
},
|
||||
hasVercelIntegration: vercelIntegration !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
type Prisma,
|
||||
Prisma,
|
||||
type WorkerDeploymentStatus,
|
||||
type WorkerInstanceGroupType,
|
||||
} from "@trigger.dev/database";
|
||||
@@ -10,6 +10,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { processGitMetadata } from "./BranchesPresenter.server";
|
||||
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
|
||||
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -105,6 +106,51 @@ export class DeploymentListPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
// Check for Vercel integration before the main query so we can conditionally LEFT JOIN
|
||||
let hasVercelIntegration = false;
|
||||
let vercelTeamSlug: string | undefined;
|
||||
let vercelProjectName: string | undefined;
|
||||
|
||||
const vercelProjectIntegration =
|
||||
await this.#prismaClient.organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
deletedAt: null,
|
||||
organizationIntegration: {
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
integrationData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (vercelProjectIntegration) {
|
||||
const parsed = VercelProjectIntegrationDataSchema.safeParse(
|
||||
vercelProjectIntegration.integrationData
|
||||
);
|
||||
|
||||
if (parsed.success && parsed.data.vercelTeamSlug) {
|
||||
hasVercelIntegration = true;
|
||||
vercelTeamSlug = parsed.data.vercelTeamSlug;
|
||||
vercelProjectName = parsed.data.vercelProjectName;
|
||||
}
|
||||
}
|
||||
|
||||
const vercelSelect = hasVercelIntegration
|
||||
? Prisma.sql`, id_dep."integrationDeploymentId"`
|
||||
: Prisma.sql``;
|
||||
const vercelJoin = hasVercelIntegration
|
||||
? Prisma.sql`LEFT JOIN LATERAL (
|
||||
SELECT id_inner."integrationDeploymentId"
|
||||
FROM ${sqlDatabaseSchema}."IntegrationDeployment" as id_inner
|
||||
WHERE id_inner."deploymentId" = wd."id" AND id_inner."integrationName" = 'vercel'
|
||||
ORDER BY id_inner."createdAt" DESC
|
||||
LIMIT 1
|
||||
) id_dep ON true`
|
||||
: Prisma.sql``;
|
||||
|
||||
const deployments = await this.#prismaClient.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
@@ -123,6 +169,7 @@ export class DeploymentListPresenter {
|
||||
userAvatarUrl: string | null;
|
||||
type: WorkerInstanceGroupType;
|
||||
git: Prisma.JsonValue | null;
|
||||
integrationDeploymentId: string | null;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
@@ -142,10 +189,12 @@ export class DeploymentListPresenter {
|
||||
wd."deployedAt",
|
||||
wd."type",
|
||||
wd."git"
|
||||
${vercelSelect}
|
||||
FROM
|
||||
${sqlDatabaseSchema}."WorkerDeployment" as wd
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
|
||||
${vercelJoin}
|
||||
WHERE
|
||||
wd."projectId" = ${project.id}
|
||||
AND wd."environmentId" = ${environment.id}
|
||||
@@ -173,6 +222,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
|
||||
return {
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
hasVercelIntegration,
|
||||
connectedGithubRepository: project.connectedGithubRepository ?? undefined,
|
||||
environmentGitHubBranch,
|
||||
deployments: deployments.map((deployment, index) => {
|
||||
@@ -180,6 +230,12 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
|
||||
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
|
||||
);
|
||||
|
||||
let vercelDeploymentUrl: string | null = null;
|
||||
if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) {
|
||||
const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, "");
|
||||
vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: deployment.id,
|
||||
shortCode: deployment.shortCode,
|
||||
@@ -210,6 +266,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
|
||||
}
|
||||
: undefined,
|
||||
git: processGitMetadata(deployment.git),
|
||||
vercelDeploymentUrl,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { flipCauseOption } from "effect/Cause";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import type { EnvironmentVariableUpdater } from "~/v3/environmentVariables/repository";
|
||||
import {
|
||||
SyncEnvVarsMapping,
|
||||
EnvSlug,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
|
||||
|
||||
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
|
||||
export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number];
|
||||
@@ -44,6 +49,9 @@ export class EnvironmentVariablesPresenter {
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
version: true,
|
||||
lastUpdatedBy: true,
|
||||
updatedAt: true,
|
||||
valueReference: {
|
||||
select: {
|
||||
key: true,
|
||||
@@ -67,6 +75,42 @@ export class EnvironmentVariablesPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const userIds = new Set(
|
||||
environmentVariables
|
||||
.flatMap((envVar) => envVar.values)
|
||||
.map((value) => value.lastUpdatedBy)
|
||||
.filter(
|
||||
(lastUpdatedBy): lastUpdatedBy is { type: "user"; userId: string } =>
|
||||
lastUpdatedBy !== null &&
|
||||
typeof lastUpdatedBy === "object" &&
|
||||
"type" in lastUpdatedBy &&
|
||||
lastUpdatedBy.type === "user" &&
|
||||
"userId" in lastUpdatedBy &&
|
||||
typeof lastUpdatedBy.userId === "string"
|
||||
)
|
||||
.map((lastUpdatedBy) => lastUpdatedBy.userId)
|
||||
);
|
||||
|
||||
const users =
|
||||
userIds.size > 0
|
||||
? await this.#prismaClient.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: Array.from(userIds),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const usersRecord: Record<string, { id: string; name: string | null; displayName: string | null; avatarUrl: string | null }> =
|
||||
Object.fromEntries(users.map((u) => [u.id, u]));
|
||||
|
||||
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -94,6 +138,18 @@ export class EnvironmentVariablesPresenter {
|
||||
const repository = new EnvironmentVariablesRepository(this.#prismaClient);
|
||||
const variables = await repository.getProject(project.id);
|
||||
|
||||
// Get Vercel integration data if it exists
|
||||
const vercelService = new VercelIntegrationService(this.#prismaClient);
|
||||
const vercelIntegration = await vercelService.getVercelProjectIntegration(project.id);
|
||||
|
||||
let vercelSyncEnvVarsMapping: SyncEnvVarsMapping = {};
|
||||
let vercelPullEnvVarsBeforeBuild: EnvSlug[] | null = null;
|
||||
|
||||
if (vercelIntegration) {
|
||||
vercelSyncEnvVarsMapping = vercelIntegration.parsedIntegrationData.syncEnvVarsMapping;
|
||||
vercelPullEnvVarsBeforeBuild = vercelIntegration.parsedIntegrationData.config.pullEnvVarsBeforeBuild ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
environmentVariables: environmentVariables
|
||||
.flatMap((environmentVariable) => {
|
||||
@@ -101,13 +157,29 @@ export class EnvironmentVariablesPresenter {
|
||||
|
||||
return sortedEnvironments.flatMap((env) => {
|
||||
const val = variable?.values.find((v) => v.environment.id === env.id);
|
||||
const isSecret =
|
||||
environmentVariable.values.find((v) => v.environmentId === env.id)?.isSecret ?? false;
|
||||
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
|
||||
const isSecret = valueRecord?.isSecret ?? false;
|
||||
|
||||
if (!val) {
|
||||
if (!val || !valueRecord) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lastUpdatedBy = valueRecord.lastUpdatedBy as EnvironmentVariableUpdater | null;
|
||||
|
||||
const updatedByUser =
|
||||
lastUpdatedBy?.type === "user"
|
||||
? (() => {
|
||||
const user = usersRecord[lastUpdatedBy.userId];
|
||||
return user
|
||||
? {
|
||||
id: user.id,
|
||||
name: user.displayName || user.name || "Unknown",
|
||||
avatarUrl: user.avatarUrl,
|
||||
}
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return [
|
||||
{
|
||||
id: environmentVariable.id,
|
||||
@@ -115,6 +187,10 @@ export class EnvironmentVariablesPresenter {
|
||||
environment: { type: env.type, id: env.id, branchName: env.branchName },
|
||||
value: isSecret ? "" : val.value,
|
||||
isSecret,
|
||||
version: valueRecord.version,
|
||||
lastUpdatedBy,
|
||||
updatedByUser,
|
||||
updatedAt: valueRecord.updatedAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -127,6 +203,14 @@ export class EnvironmentVariablesPresenter {
|
||||
branchName: environment.branchName,
|
||||
})),
|
||||
hasStaging: environments.some((environment) => environment.type === "STAGING"),
|
||||
// Vercel integration data
|
||||
vercelIntegration: vercelIntegration
|
||||
? {
|
||||
enabled: true,
|
||||
pullEnvVarsBeforeBuild: vercelPullEnvVarsBeforeBuild,
|
||||
syncEnvVarsMapping: vercelSyncEnvVarsMapping,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { type Result, fromPromise, ok, okAsync, ResultAsync } from "neverthrow";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import {
|
||||
VercelIntegrationRepository,
|
||||
VercelCustomEnvironment,
|
||||
VercelEnvironmentVariable,
|
||||
} from "~/models/vercelIntegration.server";
|
||||
import { type GitHubAppInstallation } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import {
|
||||
VercelProjectIntegrationDataSchema,
|
||||
VercelProjectIntegrationData,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type VercelSettingsOptions = {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export type VercelSettingsResult = {
|
||||
enabled: boolean;
|
||||
hasOrgIntegration: boolean;
|
||||
authInvalid?: boolean;
|
||||
connectedProject?: {
|
||||
id: string;
|
||||
vercelProjectId: string;
|
||||
vercelProjectName: string;
|
||||
vercelTeamId: string | null;
|
||||
integrationData: VercelProjectIntegrationData;
|
||||
createdAt: Date;
|
||||
};
|
||||
isGitHubConnected: boolean;
|
||||
hasStagingEnvironment: boolean;
|
||||
hasPreviewEnvironment: boolean;
|
||||
customEnvironments: VercelCustomEnvironment[];
|
||||
/** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */
|
||||
autoAssignCustomDomains?: boolean | null;
|
||||
};
|
||||
|
||||
export type VercelAvailableProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type VercelOnboardingData = {
|
||||
customEnvironments: VercelCustomEnvironment[];
|
||||
environmentVariables: VercelEnvironmentVariable[];
|
||||
availableProjects: VercelAvailableProject[];
|
||||
hasProjectSelected: boolean;
|
||||
authInvalid?: boolean;
|
||||
existingVariables: Record<string, { environments: string[] }>; // Environment slugs (non-archived only)
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
isGitHubConnected: boolean;
|
||||
isOnboardingComplete: boolean;
|
||||
};
|
||||
|
||||
export class VercelSettingsPresenter extends BasePresenter {
|
||||
/**
|
||||
* Get Vercel integration settings for the settings page
|
||||
*/
|
||||
public async call({ projectId, organizationId }: VercelSettingsOptions): Promise<Result<VercelSettingsResult, unknown>> {
|
||||
const vercelIntegrationEnabled = OrgIntegrationRepository.isVercelSupported;
|
||||
|
||||
if (!vercelIntegrationEnabled) {
|
||||
return ok({
|
||||
enabled: false,
|
||||
hasOrgIntegration: false,
|
||||
authInvalid: false,
|
||||
connectedProject: undefined,
|
||||
isGitHubConnected: false,
|
||||
hasStagingEnvironment: false,
|
||||
hasPreviewEnvironment: false,
|
||||
customEnvironments: [],
|
||||
} as VercelSettingsResult);
|
||||
}
|
||||
|
||||
const orgIntegrationResult = await fromPromise(
|
||||
(this._replica as PrismaClient).organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
}),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (orgIntegrationResult.isErr()) {
|
||||
logger.error("Unexpected error in VercelSettingsPresenter.call", { error: orgIntegrationResult.error });
|
||||
return ok({
|
||||
enabled: true,
|
||||
hasOrgIntegration: false,
|
||||
authInvalid: true,
|
||||
connectedProject: undefined,
|
||||
isGitHubConnected: false,
|
||||
hasStagingEnvironment: false,
|
||||
hasPreviewEnvironment: false,
|
||||
customEnvironments: [],
|
||||
} as VercelSettingsResult);
|
||||
}
|
||||
|
||||
const orgIntegration = orgIntegrationResult.value;
|
||||
const hasOrgIntegration = orgIntegration !== null;
|
||||
|
||||
if (hasOrgIntegration) {
|
||||
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
|
||||
if (tokenResult.isErr() || !tokenResult.value.isValid) {
|
||||
return ok({
|
||||
enabled: true,
|
||||
hasOrgIntegration: true,
|
||||
authInvalid: true,
|
||||
connectedProject: undefined,
|
||||
isGitHubConnected: false,
|
||||
hasStagingEnvironment: false,
|
||||
hasPreviewEnvironment: false,
|
||||
customEnvironments: [],
|
||||
} as VercelSettingsResult);
|
||||
}
|
||||
}
|
||||
|
||||
const checkOrgIntegration = () => fromPromise(
|
||||
Promise.resolve(hasOrgIntegration),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
|
||||
const checkGitHubConnection = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
repository: {
|
||||
installation: {
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((repo) => repo !== null);
|
||||
|
||||
const checkStagingEnvironment = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
projectId,
|
||||
type: "STAGING",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((env) => env !== null);
|
||||
|
||||
const checkPreviewEnvironment = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
projectId,
|
||||
type: "PREVIEW",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((env) => env !== null);
|
||||
|
||||
const getVercelProjectIntegration = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
deletedAt: null,
|
||||
organizationIntegration: {
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organizationIntegration: true,
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((integration) => {
|
||||
if (!integration) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsedData = VercelProjectIntegrationDataSchema.safeParse(
|
||||
integration.integrationData
|
||||
);
|
||||
|
||||
if (!parsedData.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
vercelProjectId: integration.externalEntityId,
|
||||
vercelProjectName: parsedData.data.vercelProjectName,
|
||||
vercelTeamId: parsedData.data.vercelTeamId,
|
||||
integrationData: parsedData.data,
|
||||
createdAt: integration.createdAt,
|
||||
};
|
||||
});
|
||||
|
||||
return ResultAsync.combine([
|
||||
checkOrgIntegration(),
|
||||
checkGitHubConnection(),
|
||||
checkStagingEnvironment(),
|
||||
checkPreviewEnvironment(),
|
||||
getVercelProjectIntegration(),
|
||||
]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => {
|
||||
const fetchCustomEnvsAndProjectSettings = async (): Promise<{
|
||||
customEnvironments: VercelCustomEnvironment[];
|
||||
autoAssignCustomDomains: boolean | null;
|
||||
}> => {
|
||||
if (!connectedProject || !orgIntegration) {
|
||||
return { customEnvironments: [], autoAssignCustomDomains: null };
|
||||
}
|
||||
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
|
||||
if (clientResult.isErr()) {
|
||||
return { customEnvironments: [], autoAssignCustomDomains: null };
|
||||
}
|
||||
const client = clientResult.value;
|
||||
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
||||
const [customEnvsResult, autoAssignResult] = await Promise.all([
|
||||
VercelIntegrationRepository.getVercelCustomEnvironments(
|
||||
client,
|
||||
connectedProject.vercelProjectId,
|
||||
teamId
|
||||
),
|
||||
VercelIntegrationRepository.getAutoAssignCustomDomains(
|
||||
client,
|
||||
connectedProject.vercelProjectId,
|
||||
teamId
|
||||
),
|
||||
]);
|
||||
return {
|
||||
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
|
||||
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
|
||||
};
|
||||
};
|
||||
|
||||
return fromPromise(
|
||||
fetchCustomEnvsAndProjectSettings(),
|
||||
(error) => ({ type: "other" as const, cause: error })
|
||||
).map(({ customEnvironments, autoAssignCustomDomains }) => ({
|
||||
enabled: true,
|
||||
hasOrgIntegration,
|
||||
authInvalid: false,
|
||||
connectedProject,
|
||||
isGitHubConnected,
|
||||
hasStagingEnvironment,
|
||||
hasPreviewEnvironment,
|
||||
customEnvironments,
|
||||
autoAssignCustomDomains,
|
||||
} as VercelSettingsResult));
|
||||
}).mapErr((error) => {
|
||||
// Log the error and return a safe fallback
|
||||
logger.error("Error in VercelSettingsPresenter.call", { error });
|
||||
return error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data needed for the onboarding modal (custom environments and env vars)
|
||||
*/
|
||||
public async getOnboardingData(
|
||||
projectId: string,
|
||||
organizationId: string,
|
||||
vercelEnvironmentId?: string
|
||||
): Promise<VercelOnboardingData | null> {
|
||||
const result = await ResultAsync.fromPromise(
|
||||
(async (): Promise<VercelOnboardingData | null> => {
|
||||
const [gitHubInstallations, connectedGitHubRepo] = await Promise.all([
|
||||
(this._replica as PrismaClient).githubAppInstallation.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountHandle: true,
|
||||
targetType: true,
|
||||
appInstallationId: true,
|
||||
repositories: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fullName: true,
|
||||
htmlUrl: true,
|
||||
private: true,
|
||||
},
|
||||
take: 200,
|
||||
},
|
||||
},
|
||||
take: 20,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
repository: {
|
||||
installation: {
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const isGitHubConnected = connectedGitHubRepo !== null;
|
||||
const gitHubAppInstallations: GitHubAppInstallation[] = gitHubInstallations.map((installation) => ({
|
||||
id: installation.id,
|
||||
appInstallationId: installation.appInstallationId,
|
||||
targetType: installation.targetType,
|
||||
accountHandle: installation.accountHandle,
|
||||
repositories: installation.repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
fullName: repo.fullName,
|
||||
private: repo.private,
|
||||
htmlUrl: repo.htmlUrl,
|
||||
})),
|
||||
}));
|
||||
|
||||
const orgIntegration = await (this._replica as PrismaClient).organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgIntegration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
|
||||
if (tokenResult.isErr() || !tokenResult.value.isValid) {
|
||||
return {
|
||||
customEnvironments: [],
|
||||
environmentVariables: [],
|
||||
availableProjects: [],
|
||||
hasProjectSelected: false,
|
||||
authInvalid: true,
|
||||
existingVariables: {},
|
||||
gitHubAppInstallations,
|
||||
isGitHubConnected,
|
||||
isOnboardingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
|
||||
if (clientResult.isErr()) {
|
||||
return {
|
||||
customEnvironments: [],
|
||||
environmentVariables: [],
|
||||
availableProjects: [],
|
||||
hasProjectSelected: false,
|
||||
authInvalid: clientResult.error.authInvalid,
|
||||
existingVariables: {},
|
||||
gitHubAppInstallations,
|
||||
isGitHubConnected,
|
||||
isOnboardingComplete: false,
|
||||
};
|
||||
}
|
||||
const client = clientResult.value;
|
||||
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
||||
|
||||
const projectIntegration = await (this._replica as PrismaClient).organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
deletedAt: null,
|
||||
organizationIntegration: {
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const availableProjectsResult = await VercelIntegrationRepository.getVercelProjects(client, teamId);
|
||||
|
||||
if (availableProjectsResult.isErr()) {
|
||||
return {
|
||||
customEnvironments: [],
|
||||
environmentVariables: [],
|
||||
availableProjects: [],
|
||||
hasProjectSelected: false,
|
||||
authInvalid: availableProjectsResult.error.authInvalid,
|
||||
existingVariables: {},
|
||||
gitHubAppInstallations,
|
||||
isGitHubConnected,
|
||||
isOnboardingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!projectIntegration) {
|
||||
return {
|
||||
customEnvironments: [],
|
||||
environmentVariables: [],
|
||||
availableProjects: availableProjectsResult.value,
|
||||
hasProjectSelected: false,
|
||||
existingVariables: {},
|
||||
gitHubAppInstallations,
|
||||
isGitHubConnected,
|
||||
isOnboardingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [customEnvironmentsResult, projectEnvVarsResult, sharedEnvVarsResult] = await Promise.all([
|
||||
VercelIntegrationRepository.getVercelCustomEnvironments(
|
||||
client,
|
||||
projectIntegration.externalEntityId,
|
||||
teamId
|
||||
),
|
||||
VercelIntegrationRepository.getVercelEnvironmentVariables(
|
||||
client,
|
||||
projectIntegration.externalEntityId,
|
||||
teamId
|
||||
),
|
||||
// Only fetch shared env vars if teamId is available
|
||||
teamId
|
||||
? VercelIntegrationRepository.getVercelSharedEnvironmentVariables(
|
||||
client,
|
||||
teamId,
|
||||
projectIntegration.externalEntityId
|
||||
)
|
||||
: okAsync([] as Array<{ id: string; key: string; type: string; isSecret: boolean; target: string[] }>),
|
||||
]);
|
||||
const authInvalid =
|
||||
(customEnvironmentsResult.isErr() && customEnvironmentsResult.error.authInvalid) ||
|
||||
(projectEnvVarsResult.isErr() && projectEnvVarsResult.error.authInvalid) ||
|
||||
(sharedEnvVarsResult.isErr() && sharedEnvVarsResult.error.authInvalid);
|
||||
|
||||
if (authInvalid) {
|
||||
return {
|
||||
customEnvironments: [],
|
||||
environmentVariables: [],
|
||||
availableProjects: availableProjectsResult.value,
|
||||
hasProjectSelected: true,
|
||||
authInvalid: true,
|
||||
existingVariables: {},
|
||||
gitHubAppInstallations,
|
||||
isGitHubConnected,
|
||||
isOnboardingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const customEnvironments = customEnvironmentsResult.isOk() ? customEnvironmentsResult.value : [];
|
||||
const projectEnvVars = projectEnvVarsResult.isOk() ? projectEnvVarsResult.value : [];
|
||||
const sharedEnvVars = sharedEnvVarsResult.isOk() ? sharedEnvVarsResult.value : [];
|
||||
|
||||
// Filter out TRIGGER_SECRET_KEY and TRIGGER_VERSION (managed by Trigger.dev) and merge project + shared env vars
|
||||
const excludedKeys = new Set(["TRIGGER_SECRET_KEY", "TRIGGER_VERSION"]);
|
||||
const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key));
|
||||
const mergedEnvVars: VercelEnvironmentVariable[] = [
|
||||
...projectEnvVars
|
||||
.filter((v) => !excludedKeys.has(v.key))
|
||||
.map((v) => {
|
||||
const envVar = { ...v };
|
||||
if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) {
|
||||
envVar.target = [...v.target, 'staging'];
|
||||
}
|
||||
return envVar;
|
||||
}),
|
||||
...sharedEnvVars
|
||||
.filter((v) => !projectEnvVarKeys.has(v.key) && !excludedKeys.has(v.key))
|
||||
.map((v) => {
|
||||
const envVar = {
|
||||
id: v.id,
|
||||
key: v.key,
|
||||
type: v.type as VercelEnvironmentVariable["type"],
|
||||
isSecret: v.isSecret,
|
||||
target: v.target,
|
||||
isShared: true,
|
||||
customEnvironmentIds: [] as string[],
|
||||
};
|
||||
if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) {
|
||||
envVar.target = [...v.target, 'staging'];
|
||||
}
|
||||
return envVar;
|
||||
}),
|
||||
];
|
||||
|
||||
const sortedEnvVars = [...mergedEnvVars].sort((a, b) =>
|
||||
a.key.localeCompare(b.key)
|
||||
);
|
||||
|
||||
const projectEnvs = await (this._replica as PrismaClient).runtimeEnvironment.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
archivedAt: null, // Filter out archived environments
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
const envIdToSlug = new Map(projectEnvs.map((e) => [e.id, e.slug]));
|
||||
const activeEnvIds = new Set(projectEnvs.map((e) => e.id));
|
||||
|
||||
const envVarRepository = new EnvironmentVariablesRepository(this._replica as PrismaClient);
|
||||
const existingVariables = await envVarRepository.getProject(projectId);
|
||||
const existingVariablesRecord: Record<string, { environments: string[] }> = {};
|
||||
for (const v of existingVariables) {
|
||||
// Filter out archived environments and map to slugs
|
||||
const activeEnvSlugs = v.values
|
||||
.filter((val) => activeEnvIds.has(val.environment.id))
|
||||
.map((val) => envIdToSlug.get(val.environment.id) || val.environment.type.toLowerCase());
|
||||
if (activeEnvSlugs.length > 0) {
|
||||
existingVariablesRecord[v.key] = {
|
||||
environments: activeEnvSlugs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parsedIntegrationData = VercelProjectIntegrationDataSchema.safeParse(
|
||||
projectIntegration.integrationData
|
||||
);
|
||||
|
||||
return {
|
||||
customEnvironments,
|
||||
environmentVariables: sortedEnvVars,
|
||||
availableProjects: availableProjectsResult.value,
|
||||
hasProjectSelected: true,
|
||||
existingVariables: existingVariablesRecord,
|
||||
gitHubAppInstallations,
|
||||
isGitHubConnected,
|
||||
isOnboardingComplete: parsedIntegrationData.success
|
||||
? (parsedIntegrationData.data.onboardingCompleted ?? false)
|
||||
: false,
|
||||
};
|
||||
})(),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (result.isErr()) {
|
||||
logger.error("Error in getOnboardingData", { error: result.error });
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
}
|
||||
+5
-2
@@ -51,7 +51,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
try {
|
||||
const presenter = new ApiKeysPresenter();
|
||||
const { environment } = await presenter.call({
|
||||
const { environment, hasVercelIntegration } = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
@@ -59,6 +59,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
return typedjson({
|
||||
environment,
|
||||
hasVercelIntegration,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -70,7 +71,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { environment } = useTypedLoaderData<typeof loader>();
|
||||
const { environment, hasVercelIntegration } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
|
||||
if (!environment) {
|
||||
@@ -132,6 +133,8 @@ export default function Page() {
|
||||
<RegenerateApiKeyModal
|
||||
id={environment.parentEnvironment?.id ?? environment.id}
|
||||
title={environmentFullTitle(environment)}
|
||||
hasVercelIntegration={hasVercelIntegration}
|
||||
isDevelopment={environment.type === "DEVELOPMENT"}
|
||||
/>
|
||||
</div>
|
||||
<ClipboardField
|
||||
|
||||
+27
-1
@@ -19,6 +19,7 @@ import { useEffect } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import {
|
||||
DeploymentStatus,
|
||||
deploymentStatusDescription,
|
||||
@@ -160,6 +162,7 @@ export default function Page() {
|
||||
connectedGithubRepository,
|
||||
environmentGitHubBranch,
|
||||
autoReloadPollIntervalMs,
|
||||
hasVercelIntegration,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const hasDeployments = totalPages > 0;
|
||||
|
||||
@@ -234,6 +237,7 @@ export default function Page() {
|
||||
<TableHeaderCell>Deployed at</TableHeaderCell>
|
||||
<TableHeaderCell>Deployed by</TableHeaderCell>
|
||||
<TableHeaderCell>Git</TableHeaderCell>
|
||||
{hasVercelIntegration && <TableHeaderCell>Linked</TableHeaderCell>}
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -307,6 +311,28 @@ export default function Page() {
|
||||
<GitMetadata git={deployment.git} />
|
||||
</div>
|
||||
</TableCell>
|
||||
{hasVercelIntegration && (
|
||||
<TableCell isSelected={isSelected}>
|
||||
{deployment.vercelDeploymentUrl ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<a
|
||||
href={deployment.vercelDeploymentUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center text-text-dimmed transition-colors hover:text-text-bright"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VercelLogo className="size-3.5" />
|
||||
</a>
|
||||
}
|
||||
content="View on Vercel"
|
||||
/>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<DeploymentActionsCell
|
||||
deployment={deployment}
|
||||
path={path}
|
||||
@@ -317,7 +343,7 @@ export default function Page() {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<TableBlankRow colSpan={hasVercelIntegration ? 9 : 8}>
|
||||
<Paragraph className="flex items-center justify-center">
|
||||
No deploys match your filters
|
||||
</Paragraph>
|
||||
|
||||
+7
-1
@@ -151,7 +151,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(prisma);
|
||||
const result = await repository.create(project.id, submission.value);
|
||||
const result = await repository.create(project.id, {
|
||||
...submission.value,
|
||||
lastUpdatedBy: {
|
||||
type: "user",
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
if (result.variableErrors) {
|
||||
|
||||
+218
-12
@@ -9,7 +9,7 @@ import {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation } from "@remix-run/react";
|
||||
import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation, useRevalidator } from "@remix-run/react";
|
||||
import {
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
@@ -19,10 +19,12 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
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";
|
||||
@@ -70,6 +72,11 @@ import {
|
||||
EditEnvironmentVariableValue,
|
||||
EnvironmentVariable,
|
||||
} from "~/v3/environmentVariables/repository";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { shouldSyncEnvVar, isPullEnvVarsEnabledForEnvironment, type TriggerEnvironmentType } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -85,7 +92,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
try {
|
||||
const presenter = new EnvironmentVariablesPresenter();
|
||||
const { environmentVariables, environments, hasStaging } = await presenter.call({
|
||||
const { environmentVariables, environments, hasStaging, vercelIntegration } = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
@@ -94,6 +101,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
environmentVariables,
|
||||
environments,
|
||||
hasStaging,
|
||||
vercelIntegration,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -111,6 +119,12 @@ const schema = z.discriminatedUnion("action", [
|
||||
key: z.string(),
|
||||
...DeleteEnvironmentVariableValue.shape,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("update-vercel-sync"),
|
||||
key: z.string(),
|
||||
environmentType: z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]),
|
||||
syncEnabled: z.union([z.literal("true"), z.literal("false")]).transform((val) => val === "true"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
@@ -151,7 +165,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
switch (submission.value.action) {
|
||||
case "edit": {
|
||||
const repository = new EnvironmentVariablesRepository(prisma);
|
||||
const result = await repository.editValue(project.id, submission.value);
|
||||
const result = await repository.editValue(project.id, {
|
||||
...submission.value,
|
||||
lastUpdatedBy: {
|
||||
type: "user",
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.key = [result.error];
|
||||
@@ -169,6 +189,32 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
// Clean up syncEnvVarsMapping if Vercel integration exists (best-effort)
|
||||
const { environmentId, key } = submission.value;
|
||||
const vercelService = new VercelIntegrationService();
|
||||
await fromPromise(
|
||||
(async () => {
|
||||
const integration = await vercelService.getVercelProjectIntegration(project.id);
|
||||
if (integration) {
|
||||
const runtimeEnv = await prisma.runtimeEnvironment.findUnique({
|
||||
where: { id: environmentId },
|
||||
select: { type: true },
|
||||
});
|
||||
if (runtimeEnv) {
|
||||
await vercelService.removeSyncEnvVarForEnvironment(
|
||||
project.id,
|
||||
key,
|
||||
runtimeEnv.type as TriggerEnvironmentType
|
||||
);
|
||||
}
|
||||
}
|
||||
})(),
|
||||
(error) => error
|
||||
).mapErr((error) => {
|
||||
logger.error("Failed to remove Vercel sync mapping", { error });
|
||||
return error;
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3EnvironmentVariablesPath(
|
||||
{ slug: organizationSlug },
|
||||
@@ -179,12 +225,31 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
`Deleted ${submission.value.key} environment variable`
|
||||
);
|
||||
}
|
||||
case "update-vercel-sync": {
|
||||
const vercelService = new VercelIntegrationService();
|
||||
const integration = await vercelService.getVercelProjectIntegration(project.id);
|
||||
|
||||
if (!integration) {
|
||||
submission.error.key = ["Vercel integration not found"];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
// Update the sync mapping for the specific env var and environment
|
||||
await vercelService.updateSyncEnvVarForEnvironment(
|
||||
project.id,
|
||||
submission.value.key,
|
||||
submission.value.environmentType,
|
||||
submission.value.syncEnabled
|
||||
);
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [revealAll, setRevealAll] = useState(false);
|
||||
const { environmentVariables, environments } = useTypedLoaderData<typeof loader>();
|
||||
const { environmentVariables, environments, vercelIntegration } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -279,10 +344,32 @@ export default function Page() {
|
||||
<Table containerClassName={cn(filteredItems.length === 0 && "border-t-0")}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-[25%]">Key</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[55%]">Value</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[20%]">Environment</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel className="pl-24">
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[22%]" : "w-[25%]"}>
|
||||
Key
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[32%]" : "w-[37%]"}>
|
||||
Value
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[13%]" : "w-[15%]"}>
|
||||
Environment
|
||||
</TableHeaderCell>
|
||||
{vercelIntegration?.enabled && (
|
||||
<TableHeaderCell className="w-[8%]">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
Sync
|
||||
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
||||
</span>
|
||||
}
|
||||
content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings."
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[24%]" : "w-[22%]"}>
|
||||
Updated
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel className="w-0">
|
||||
Actions
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
@@ -341,9 +428,54 @@ export default function Page() {
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
<EnvironmentCombo environment={variable.environment} className="text-sm" />
|
||||
</TableCell>
|
||||
{vercelIntegration?.enabled && (
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
{variable.environment.type !== "DEVELOPMENT" && (
|
||||
<VercelSyncCheckbox
|
||||
envVarKey={variable.key}
|
||||
environmentType={variable.environment.type as TriggerEnvironmentType}
|
||||
syncEnabled={shouldSyncEnvVar(
|
||||
vercelIntegration.syncEnvVarsMapping,
|
||||
variable.key,
|
||||
variable.environment.type as TriggerEnvironmentType
|
||||
)}
|
||||
pullEnvVarsEnabledForEnv={isPullEnvVarsEnabledForEnvironment(
|
||||
vercelIntegration.pullEnvVarsBeforeBuild,
|
||||
variable.environment.type as TriggerEnvironmentType
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{variable.updatedByUser ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={variable.updatedByUser.avatarUrl}
|
||||
name={variable.updatedByUser.name}
|
||||
className="size-5"
|
||||
/>
|
||||
<span className="text-sm">{variable.updatedByUser.name}</span>
|
||||
</div>
|
||||
) : (variable.lastUpdatedBy?.type === "integration" && variable.lastUpdatedBy?.integration === 'vercel' ) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<VercelLogo className="size-4 text-text-dimmed group-hover/table-row:text-text-bright transition-colors" />
|
||||
<span className="text-sm text-text-dimmed group-hover/table-row:text-text-bright capitalize transition-colors">
|
||||
{variable.lastUpdatedBy.integration}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{variable.updatedAt ? (
|
||||
<span className="text-sm text-text-dimmed">
|
||||
<DateTime date={variable.updatedAt} includeSeconds={false} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
className={cn(cellClassName, borderedCellClassName)}
|
||||
isSticky
|
||||
className="w-0 [&:has(.group-hover/table-row:block)]:w-auto"
|
||||
hiddenButtons={
|
||||
<>
|
||||
<EditEnvironmentVariablePanel
|
||||
@@ -359,7 +491,7 @@ export default function Page() {
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<TableCell colSpan={vercelIntegration?.enabled ? 6 : 5}>
|
||||
{environmentVariables.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Header2>You haven't set any environment variables yet.</Header2>
|
||||
@@ -430,7 +562,7 @@ function EditEnvironmentVariablePanel({
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="small-menu-item" LeadingIcon={PencilSquareIcon} fullWidth textAlignLeft>
|
||||
Edit
|
||||
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -526,8 +658,82 @@ function DeleteEnvironmentVariableButton({
|
||||
leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors"
|
||||
className="ml-0.5 transition-colors group-hover/button:bg-error"
|
||||
>
|
||||
{isLoading ? "Deleting" : "Delete"}
|
||||
{isLoading ? "Deleting" : ""}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle component for controlling whether an environment variable is pulled from Vercel.
|
||||
*
|
||||
* When enabled, the variable will be pulled from Vercel during builds.
|
||||
* By default, all variables are pulled unless explicitly disabled.
|
||||
*
|
||||
* Note: If the env slug is missing from syncEnvVarsMapping, all vars are pulled by default.
|
||||
* Only when syncEnvVarsMapping[envSlug][envVarName] = false, the env var is skipped during builds.
|
||||
*/
|
||||
function VercelSyncCheckbox({
|
||||
envVarKey,
|
||||
environmentType,
|
||||
syncEnabled,
|
||||
pullEnvVarsEnabledForEnv,
|
||||
}: {
|
||||
envVarKey: string;
|
||||
environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT";
|
||||
syncEnabled: boolean;
|
||||
pullEnvVarsEnabledForEnv: boolean;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const isLoading = fetcher.state !== "idle";
|
||||
|
||||
// Revalidate loader data after successful submission (without full page reload)
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data) {
|
||||
const data = fetcher.data as { success?: boolean };
|
||||
if (data.success) {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, revalidator]);
|
||||
|
||||
const handleChange = (checked: boolean) => {
|
||||
fetcher.submit(
|
||||
{
|
||||
action: "update-vercel-sync",
|
||||
key: envVarKey,
|
||||
environmentType,
|
||||
syncEnabled: checked.toString(),
|
||||
},
|
||||
{ method: "post" }
|
||||
);
|
||||
};
|
||||
|
||||
// If pull env vars is disabled for this environment, show disabled state
|
||||
if (!pullEnvVarsEnabledForEnv) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={false}
|
||||
disabled
|
||||
onCheckedChange={() => {}}
|
||||
/>
|
||||
}
|
||||
content="Enable 'Pull env vars before build' for this environment in Vercel settings."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={syncEnabled}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+159
-3
@@ -38,12 +38,20 @@ import {
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
import {
|
||||
VercelSettingsPanel,
|
||||
VercelOnboardingModal,
|
||||
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -92,6 +100,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
buildSettings,
|
||||
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -290,12 +299,121 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
|
||||
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Vercel onboarding modal state
|
||||
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
|
||||
const nextUrl = searchParams.get("next");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
|
||||
|
||||
// Helper to open modal and ensure query param is present
|
||||
const openVercelOnboarding = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
// Ensure query param is present to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
const closeVercelOnboarding = useCallback(() => {
|
||||
// Remove query param if present
|
||||
if (hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.delete("vercelOnboarding");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
// Close modal
|
||||
setIsModalOpen(false);
|
||||
}, [hasQueryParam, setSearchParams]);
|
||||
|
||||
// When query param is present, handle modal opening
|
||||
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelIntegrationEnabled) {
|
||||
// Ensure query param is present and modal is open
|
||||
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data is loaded, ensure modal is open (query param takes precedence)
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
|
||||
// Load onboarding data
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
} else if (!hasQueryParam && isModalOpen) {
|
||||
// Query param removed but modal is open, close modal
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// Ensure modal stays open when query param is present (even after data reloads)
|
||||
// This is a safeguard to prevent the modal from closing during form submissions
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && !isModalOpen) {
|
||||
// Query param is present but modal is closed, open it
|
||||
// This ensures the modal stays open during the onboarding flow
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
// When data finishes loading (from query param), ensure modal is open
|
||||
useEffect(() => {
|
||||
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded and query param is present, ensure modal is open
|
||||
if (!isModalOpen) {
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}
|
||||
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
|
||||
|
||||
|
||||
// Track if we're waiting for data from button click (not query param)
|
||||
const waitingForButtonClickRef = useRef(false);
|
||||
|
||||
// Handle opening modal from button click (without query param)
|
||||
const handleOpenVercelModal = useCallback(() => {
|
||||
// Add query param to maintain state during form submissions
|
||||
if (!hasQueryParam) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("vercelOnboarding", "true");
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
|
||||
// Data already loaded, open modal immediately
|
||||
openVercelOnboarding();
|
||||
} else {
|
||||
// Need to load data first, mark that we're waiting for button click
|
||||
waitingForButtonClickRef.current = true;
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
|
||||
);
|
||||
}
|
||||
}, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]);
|
||||
|
||||
// When data loads from button click, open modal
|
||||
useEffect(() => {
|
||||
if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
|
||||
// Data loaded from button click, open modal and ensure query param is present
|
||||
waitingForButtonClickRef.current = false;
|
||||
openVercelOnboarding();
|
||||
}
|
||||
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
|
||||
|
||||
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
|
||||
|
||||
@@ -425,6 +543,21 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vercelIntegrationEnabled && (
|
||||
<div>
|
||||
<Header2 spacing>Vercel integration</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
<VercelSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
onOpenVercelModal={handleOpenVercelModal}
|
||||
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Build settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
@@ -477,6 +610,29 @@ export default function Page() {
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
|
||||
{/* Vercel Onboarding Modal */}
|
||||
{vercelIntegrationEnabled && (
|
||||
<VercelOnboardingModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeVercelOnboarding}
|
||||
onboardingData={vercelFetcher.data?.onboardingData ?? null}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
|
||||
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
|
||||
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
|
||||
nextUrl={nextUrl ?? undefined}
|
||||
onDataReload={(vercelEnvironmentId) => {
|
||||
vercelFetcher.load(
|
||||
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
|
||||
vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : ""
|
||||
}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import type {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
} from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "~/components/primitives/Table";
|
||||
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { requireOrganization } from "~/services/org.server";
|
||||
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
const url = new URL(request.url);
|
||||
const configurationId = url.searchParams.get("configurationId") ?? undefined;
|
||||
const { organization } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
// Find Vercel integration for this organization
|
||||
let vercelIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
// If configurationId is provided, filter by it in integrationData
|
||||
...(configurationId && {
|
||||
integrationData: {
|
||||
path: ["installationId"],
|
||||
equals: configurationId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!vercelIntegration) {
|
||||
return typedjson({
|
||||
organization,
|
||||
vercelIntegration: null,
|
||||
connectedProjects: [],
|
||||
teamId: null,
|
||||
installationId: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Get team ID from integrationData
|
||||
const integrationData = vercelIntegration.integrationData as any;
|
||||
const teamId = integrationData?.teamId ?? null;
|
||||
const installationId = integrationData?.installationId ?? null;
|
||||
|
||||
// Get all connected projects for this integration
|
||||
const connectedProjects = await prisma.organizationProjectIntegration.findMany({
|
||||
where: {
|
||||
organizationIntegrationId: vercelIntegration.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
organization,
|
||||
vercelIntegration,
|
||||
connectedProjects,
|
||||
teamId,
|
||||
installationId,
|
||||
});
|
||||
};
|
||||
|
||||
const ActionSchema = z.object({
|
||||
intent: z.literal("uninstall"),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
const { organization, userId } = await requireOrganization(request, organizationSlug);
|
||||
|
||||
const formData = await request.formData();
|
||||
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
|
||||
if (!result.success) {
|
||||
return json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find Vercel integration
|
||||
const vercelIntegration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!vercelIntegration) {
|
||||
return json({ error: "Vercel integration not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Uninstall from Vercel side
|
||||
const uninstallResult = await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration);
|
||||
|
||||
if (uninstallResult.isErr()) {
|
||||
logger.error("Failed to uninstall Vercel integration", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: vercelIntegration.id,
|
||||
error: uninstallResult.error.message,
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: "Failed to uninstall Vercel integration. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Soft-delete the integration and all connected projects in a transaction
|
||||
const txResult = await fromPromise(
|
||||
$transaction(prisma, async (tx) => {
|
||||
await tx.organizationProjectIntegration.updateMany({
|
||||
where: {
|
||||
organizationIntegrationId: vercelIntegration.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await tx.organizationIntegration.update({
|
||||
where: { id: vercelIntegration.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (txResult.isErr()) {
|
||||
logger.error("Failed to soft-delete Vercel integration records", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: vercelIntegration.id,
|
||||
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: "Failed to uninstall Vercel integration. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (uninstallResult.value.authInvalid) {
|
||||
logger.warn("Vercel integration uninstalled with auth error - token invalid", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: vercelIntegration.id,
|
||||
});
|
||||
} else {
|
||||
logger.info("Vercel integration uninstalled successfully", {
|
||||
organizationId: organization.id,
|
||||
organizationSlug,
|
||||
userId,
|
||||
integrationId: vercelIntegration.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect back to organization settings
|
||||
return redirect(`/orgs/${organizationSlug}/settings`);
|
||||
};
|
||||
|
||||
export default function VercelIntegrationPage() {
|
||||
const { organization, vercelIntegration, connectedProjects, teamId, installationId } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const isUninstalling = navigation.state === "submitting" &&
|
||||
navigation.formData?.get("intent") === "uninstall";
|
||||
|
||||
if (!vercelIntegration) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Header1>No Vercel Integration Found</Header1>
|
||||
<Paragraph className="mt-2 text-center text-text-dimmed">
|
||||
This organization doesn't have a Vercel integration configured.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="mb-8">
|
||||
<Header1>Vercel Integration</Header1>
|
||||
<Paragraph className="mt-2 text-text-dimmed">
|
||||
Manage your organization's Vercel integration and connected projects.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Integration Info Section */}
|
||||
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
|
||||
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
|
||||
{teamId && (
|
||||
<div>
|
||||
<span className="font-medium">Vercel Team ID:</span> {teamId}
|
||||
</div>
|
||||
)}
|
||||
{installationId && (
|
||||
<div>
|
||||
<span className="font-medium">Installation ID:</span> {installationId}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">Installed:</span>{" "}
|
||||
{formatDate(new Date(vercelIntegration.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
disabled={isUninstalling}
|
||||
>
|
||||
Remove Integration
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Vercel Integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
This will permanently remove the Vercel integration and disconnect all projects.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="uninstall" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
type="submit"
|
||||
disabled={isUninstalling}
|
||||
>
|
||||
{isUninstalling ? "Removing..." : "Remove Integration"}
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{actionData?.error && (
|
||||
<Paragraph variant="small" className="text-error">
|
||||
{actionData.error}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected Projects Section */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-bright">
|
||||
Connected Projects ({connectedProjects.length})
|
||||
</h2>
|
||||
|
||||
{connectedProjects.length === 0 ? (
|
||||
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
|
||||
<Paragraph className="text-text-dimmed">
|
||||
No projects are currently connected to this Vercel integration.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Project Name</TableHeaderCell>
|
||||
<TableHeaderCell>Vercel Project ID</TableHeaderCell>
|
||||
<TableHeaderCell>Connected</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectedProjects.map((projectIntegration) => (
|
||||
<TableRow key={projectIntegration.id}>
|
||||
<TableCell>{projectIntegration.project.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{projectIntegration.externalEntityId}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(new Date(projectIntegration.createdAt))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
to={v3ProjectSettingsPath(
|
||||
organization,
|
||||
projectIntegration.project,
|
||||
{ slug: "prod" } // Default to production environment
|
||||
)}
|
||||
>
|
||||
Configure
|
||||
</LinkButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
selectPlanPath,
|
||||
v3ProjectPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -103,6 +104,12 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
// Check for Vercel integration params in URL
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const configurationId = url.searchParams.get("configurationId");
|
||||
const next = url.searchParams.get("next");
|
||||
|
||||
try {
|
||||
const project = await createProject({
|
||||
organizationSlug: organizationSlug,
|
||||
@@ -111,6 +118,44 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
version: submission.value.projectVersion,
|
||||
});
|
||||
|
||||
// If this is a Vercel integration flow, generate state and redirect to connect
|
||||
if (code && configurationId) {
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
slug: "prod",
|
||||
archivedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Failed to find project environment."
|
||||
);
|
||||
}
|
||||
|
||||
const state = await generateVercelOAuthState({
|
||||
organizationId: project.organization.id,
|
||||
projectId: project.id,
|
||||
environmentSlug: environment.slug,
|
||||
organizationSlug: project.organization.slug,
|
||||
projectSlug: project.slug,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
state,
|
||||
code,
|
||||
configurationId,
|
||||
origin: "marketplace",
|
||||
});
|
||||
if (next) {
|
||||
params.set("next", next);
|
||||
}
|
||||
return redirect(`/vercel/connect?${params.toString()}`);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectPath(project.organization, project),
|
||||
request,
|
||||
|
||||
@@ -69,6 +69,27 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Preserve Vercel integration params if present
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const configurationId = url.searchParams.get("configurationId");
|
||||
const integration = url.searchParams.get("integration");
|
||||
const next = url.searchParams.get("next");
|
||||
|
||||
if (code && configurationId && integration === "vercel") {
|
||||
// Redirect to projects/new with params preserved
|
||||
const params = new URLSearchParams({
|
||||
code,
|
||||
configurationId,
|
||||
integration,
|
||||
});
|
||||
if (next) {
|
||||
params.set("next", next);
|
||||
}
|
||||
const redirectUrl = `${organizationPath(organization)}/projects/new?${params.toString()}`;
|
||||
return redirect(redirectUrl);
|
||||
}
|
||||
|
||||
return redirect(organizationPath(organization));
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
tasks: true,
|
||||
},
|
||||
},
|
||||
integrationDeployments: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -54,6 +55,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
version: deployment.version,
|
||||
imageReference: deployment.imageReference,
|
||||
imagePlatform: deployment.imagePlatform,
|
||||
commitSHA: deployment.commitSHA,
|
||||
externalBuildData:
|
||||
deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"],
|
||||
errorData: deployment.errorData as GetDeploymentResponseBody["errorData"],
|
||||
@@ -69,5 +71,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
integrationDeployments:
|
||||
deployment.integrationDeployments.length > 0
|
||||
? deployment.integrationDeployments.map((id) => ({
|
||||
id: id.id,
|
||||
integrationName: id.integrationName,
|
||||
integrationDeploymentId: id.integrationDeploymentId,
|
||||
commitSHA: id.commitSHA,
|
||||
createdAt: id.createdAt,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies GetDeploymentResponseBody);
|
||||
}
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
organizationSlug: z.string(),
|
||||
projectParam: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* API endpoint to retrieve connected Vercel projects for a Trigger.dev project.
|
||||
*
|
||||
* GET /api/v1/orgs/:organizationSlug/projects/:projectParam/vercel/projects
|
||||
*
|
||||
* Returns:
|
||||
* - vercelProject: The connected Vercel project details (if any)
|
||||
* - config: The Vercel integration configuration
|
||||
* - syncEnvVarsMapping: The environment variable sync mapping
|
||||
*/
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
// Handle CORS
|
||||
if (request.method === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: "Invalid or Missing Access Token" }, { status: 401 })
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
if (!parsedParams.success) {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: "Invalid parameters" }, { status: 400 })
|
||||
);
|
||||
}
|
||||
|
||||
const { organizationSlug, projectParam } = parsedParams.data;
|
||||
|
||||
const result = await fromPromise(
|
||||
(async () => {
|
||||
// Find the project, verifying org membership
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
organizationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return { type: "not_found" as const };
|
||||
}
|
||||
|
||||
// Get Vercel integration for the project
|
||||
const vercelService = new VercelIntegrationService();
|
||||
const integration = await vercelService.getVercelProjectIntegration(project.id);
|
||||
|
||||
return { type: "success" as const, project, integration };
|
||||
})(),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (result.isErr()) {
|
||||
logger.error("Failed to fetch Vercel projects", {
|
||||
error: result.error,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
});
|
||||
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: "Internal server error" }, { status: 500 })
|
||||
);
|
||||
}
|
||||
|
||||
if (result.value.type === "not_found") {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: "Project not found" }, { status: 404 })
|
||||
);
|
||||
}
|
||||
|
||||
const { project, integration } = result.value;
|
||||
|
||||
if (!integration) {
|
||||
return apiCors(
|
||||
request,
|
||||
json({
|
||||
connected: false,
|
||||
vercelProject: null,
|
||||
config: null,
|
||||
syncEnvVarsMapping: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const { parsedIntegrationData } = integration;
|
||||
|
||||
return apiCors(
|
||||
request,
|
||||
json({
|
||||
connected: true,
|
||||
vercelProject: {
|
||||
id: parsedIntegrationData.vercelProjectId,
|
||||
name: parsedIntegrationData.vercelProjectName,
|
||||
teamId: parsedIntegrationData.vercelTeamId,
|
||||
},
|
||||
config: {
|
||||
atomicBuilds: parsedIntegrationData.config.atomicBuilds,
|
||||
pullEnvVarsBeforeBuild: parsedIntegrationData.config.pullEnvVarsBeforeBuild,
|
||||
vercelStagingEnvironment: parsedIntegrationData.config.vercelStagingEnvironment,
|
||||
},
|
||||
syncEnvVarsMapping: parsedIntegrationData.syncEnvVarsMapping,
|
||||
triggerProject: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,10 +41,13 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
||||
const result = await repository.create(environment.project.id, {
|
||||
override: typeof body.override === "boolean" ? body.override : false,
|
||||
environmentIds: [environment.id],
|
||||
// Pass parent environment ID so new variables can inherit isSecret from parent
|
||||
parentEnvironmentId: environment.parentEnvironmentId ?? undefined,
|
||||
variables: Object.entries(body.variables).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
})),
|
||||
lastUpdatedBy: body.source,
|
||||
});
|
||||
|
||||
// Only sync parent variables if this is a branch environment
|
||||
@@ -56,6 +59,7 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
||||
key,
|
||||
value,
|
||||
})),
|
||||
lastUpdatedBy: body.source,
|
||||
});
|
||||
|
||||
let childFailure = !result.success ? result : undefined;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { commitSession } from "~/services/sessionStorage.server";
|
||||
import { trackAndClearReferralSource } from "~/services/referralSource.server";
|
||||
import { redirectCookie } from "./auth.github";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
@@ -17,7 +18,6 @@ export let loader: LoaderFunction = async ({ request }) => {
|
||||
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
|
||||
});
|
||||
|
||||
// manually get the session
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
const userRecord = await prisma.user.findFirst({
|
||||
@@ -49,12 +49,13 @@ export let loader: LoaderFunction = async ({ request }) => {
|
||||
return redirect("/login/mfa", { headers });
|
||||
}
|
||||
|
||||
// and store the user data
|
||||
session.set(authenticator.sessionKey, auth);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
|
||||
|
||||
await trackAndClearReferralSource(request, auth.userId, headers);
|
||||
|
||||
return redirect(redirectTo, { headers });
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { commitSession } from "~/services/sessionStorage.server";
|
||||
import { trackAndClearReferralSource } from "~/services/referralSource.server";
|
||||
import { redirectCookie } from "./auth.google";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
@@ -17,7 +18,6 @@ export let loader: LoaderFunction = async ({ request }) => {
|
||||
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
|
||||
});
|
||||
|
||||
// manually get the session
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
const userRecord = await prisma.user.findFirst({
|
||||
@@ -49,13 +49,14 @@ export let loader: LoaderFunction = async ({ request }) => {
|
||||
return redirect("/login/mfa", { headers });
|
||||
}
|
||||
|
||||
// and store the user data
|
||||
session.set(authenticator.sessionKey, auth);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
|
||||
|
||||
await trackAndClearReferralSource(request, auth.userId, headers);
|
||||
|
||||
return redirect(redirectTo, { headers });
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { updateUser } from "~/models/user.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { rootPath } from "~/utils/pathBuilder";
|
||||
import { getVercelInstallParams } from "~/v3/vercel";
|
||||
|
||||
function createSchema(
|
||||
constraints: {
|
||||
@@ -105,7 +106,24 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
referralSource: submission.value.referralSource,
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(rootPath(), request, "Your details have been updated.");
|
||||
// Preserve Vercel integration params if present
|
||||
const vercelParams = getVercelInstallParams(request);
|
||||
let redirectUrl = rootPath();
|
||||
|
||||
if (vercelParams) {
|
||||
// Redirect to orgs/new with params preserved
|
||||
const params = new URLSearchParams({
|
||||
code: vercelParams.code,
|
||||
configurationId: vercelParams.configurationId,
|
||||
integration: "vercel",
|
||||
});
|
||||
if (vercelParams.next) {
|
||||
params.set("next", vercelParams.next);
|
||||
}
|
||||
redirectUrl = `/orgs/new?${params.toString()}`;
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(redirectUrl, request, "Your details have been updated.");
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function LoginPage() {
|
||||
<div className="relative w-full">
|
||||
{data.lastAuthMethod === "email" && <LastUsedBadge />}
|
||||
<LinkButton
|
||||
to="/login/magic"
|
||||
to={data.redirectTo ? `/login/magic?redirectTo=${encodeURIComponent(data.redirectTo)}` : "/login/magic"}
|
||||
variant="secondary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with email"
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
|
||||
import { setRedirectTo, commitSession as commitRedirectSession } from "~/services/redirectTo.server";
|
||||
import {
|
||||
checkMagicLinkEmailRateLimit,
|
||||
checkMagicLinkEmailDailyRateLimit,
|
||||
@@ -59,6 +60,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getUserSession(request);
|
||||
const error = session.get("auth:error");
|
||||
|
||||
// Get redirectTo from URL params and store in session if present
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = url.searchParams.get("redirectTo");
|
||||
const headers = new Headers();
|
||||
|
||||
if (redirectTo) {
|
||||
const redirectSession = await setRedirectTo(request, redirectTo);
|
||||
headers.append("Set-Cookie", await commitRedirectSession(redirectSession));
|
||||
}
|
||||
|
||||
let magicLinkError: string | undefined;
|
||||
if (error) {
|
||||
if ("message" in error) {
|
||||
@@ -68,13 +79,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
|
||||
return typedjson(
|
||||
{
|
||||
magicLinkSent: session.has("triggerdotdev:magiclink"),
|
||||
magicLinkError,
|
||||
},
|
||||
{
|
||||
headers: { "Set-Cookie": await commitSession(session) },
|
||||
headers,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuth
|
||||
import { redirectWithErrorMessage, redirectBackWithErrorMessage } from "~/models/message.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server";
|
||||
import { trackAndClearReferralSource } from "~/services/referralSource.server";
|
||||
|
||||
export const meta: MetaFunction = ({ matches }) => {
|
||||
const parentMeta = matches
|
||||
@@ -160,11 +161,13 @@ async function completeLogin(request: Request, session: Session, userId: string)
|
||||
session.unset("pending-mfa-user-id");
|
||||
session.unset("pending-mfa-redirect-to");
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await sessionStorage.commitSession(authSession),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await sessionStorage.commitSession(authSession));
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
|
||||
await trackAndClearReferralSource(request, userId, headers);
|
||||
|
||||
return redirect(redirectTo, { headers });
|
||||
}
|
||||
|
||||
export default function LoginMfaPage() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { getRedirectTo } from "~/services/redirectTo.server";
|
||||
import { commitSession, getSession } from "~/services/sessionStorage.server";
|
||||
import { trackAndClearReferralSource } from "~/services/referralSource.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const redirectTo = await getRedirectTo(request);
|
||||
@@ -53,5 +54,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
|
||||
|
||||
await trackAndClearReferralSource(request, auth.userId, headers);
|
||||
|
||||
return redirect(redirectTo ?? "/", { headers });
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { regenerateApiKey } from "~/models/api-key.server";
|
||||
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
|
||||
import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
environmentId: z.string(),
|
||||
@@ -19,9 +21,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
const { environmentId } = ParamsSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const syncToVercel = formData.get("syncToVercel") === "on";
|
||||
|
||||
try {
|
||||
const updatedEnvironment = await regenerateApiKey({ userId, environmentId });
|
||||
|
||||
// Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT
|
||||
if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") {
|
||||
await syncApiKeyToVercel(
|
||||
updatedEnvironment.projectId,
|
||||
updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW",
|
||||
updatedEnvironment.apiKey
|
||||
);
|
||||
}
|
||||
|
||||
return jsonWithSuccessMessage(
|
||||
{ ok: true },
|
||||
request,
|
||||
@@ -37,3 +51,27 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the API key to Vercel.
|
||||
* Errors are logged but won't fail the API key regeneration.
|
||||
*/
|
||||
async function syncApiKeyToVercel(
|
||||
projectId: string,
|
||||
environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT",
|
||||
apiKey: string
|
||||
): Promise<void> {
|
||||
const result = await VercelIntegrationRepository.syncSingleApiKeyToVercel({
|
||||
projectId,
|
||||
environmentType,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
if (result.isErr()) {
|
||||
logger.warn("syncSingleApiKeyToVercel returned failure", {
|
||||
projectId,
|
||||
environmentType,
|
||||
error: result.error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+31
-5
@@ -330,12 +330,15 @@ export function ConnectGitHubRepoModal({
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
redirectUrl,
|
||||
preventDismiss,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
redirectUrl?: string;
|
||||
/** When true, prevents closing the modal via Escape key or clicking outside */
|
||||
preventDismiss?: boolean;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const lastSubmission = useActionData() as any;
|
||||
@@ -385,13 +388,34 @@ export function ConnectGitHubRepoModal({
|
||||
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
// When preventDismiss is true, only allow opening, not closing
|
||||
if (preventDismiss && !open) {
|
||||
return;
|
||||
}
|
||||
setIsModalOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
|
||||
Connect GitHub repo
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent
|
||||
showCloseButton={!preventDismiss}
|
||||
onInteractOutside={(e) => {
|
||||
if (preventDismiss) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (preventDismiss) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>Connect GitHub repository</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<Form method="post" action={actionUrl} {...form.props} className="w-full">
|
||||
@@ -514,9 +538,11 @@ export function ConnectGitHubRepoModal({
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
preventDismiss ? undefined : (
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
+926
@@ -0,0 +1,926 @@
|
||||
import { useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useFetcher,
|
||||
useNavigation,
|
||||
useLocation,
|
||||
} from "@remix-run/react";
|
||||
import {
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
json,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedFetcher } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
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 { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
redirectWithErrorMessage,
|
||||
} from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
|
||||
import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
VercelSettingsPresenter,
|
||||
type VercelOnboardingData,
|
||||
} from "~/presenters/v3/VercelSettingsPresenter.server";
|
||||
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
|
||||
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
|
||||
import {
|
||||
type VercelProjectIntegrationData,
|
||||
type SyncEnvVarsMapping,
|
||||
type EnvSlug,
|
||||
envSlugArrayField,
|
||||
envTypeToSlug,
|
||||
getAvailableEnvSlugs,
|
||||
getAvailableEnvSlugsForBuildSettings,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { Result, fromPromise } from "neverthrow";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ConnectedVercelProject = {
|
||||
id: string;
|
||||
vercelProjectId: string;
|
||||
vercelProjectName: string;
|
||||
vercelTeamId: string | null;
|
||||
integrationData: VercelProjectIntegrationData;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
const safeJsonParse = Result.fromThrowable(
|
||||
(val: string) => JSON.parse(val) as Record<string, unknown>,
|
||||
() => null
|
||||
);
|
||||
|
||||
function parseVercelStagingEnvironment(
|
||||
value: string | null | undefined
|
||||
): { environmentId: string; displayName: string } | null {
|
||||
if (!value) return null;
|
||||
return safeJsonParse(value).match(
|
||||
(parsed) => {
|
||||
if (typeof parsed?.environmentId === "string" && typeof parsed?.displayName === "string") {
|
||||
return { environmentId: parsed.environmentId, displayName: parsed.displayName };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
() => null
|
||||
);
|
||||
}
|
||||
|
||||
const UpdateVercelConfigFormSchema = z.object({
|
||||
action: z.literal("update-config"),
|
||||
atomicBuilds: envSlugArrayField,
|
||||
pullEnvVarsBeforeBuild: envSlugArrayField,
|
||||
discoverEnvVars: envSlugArrayField,
|
||||
vercelStagingEnvironment: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const DisconnectVercelFormSchema = z.object({
|
||||
action: z.literal("disconnect"),
|
||||
});
|
||||
|
||||
const CompleteOnboardingFormSchema = z.object({
|
||||
action: z.literal("complete-onboarding"),
|
||||
vercelStagingEnvironment: z.string().nullable().optional(),
|
||||
pullEnvVarsBeforeBuild: envSlugArrayField,
|
||||
atomicBuilds: envSlugArrayField,
|
||||
discoverEnvVars: envSlugArrayField,
|
||||
syncEnvVarsMapping: z.string().optional(),
|
||||
next: z.string().optional(),
|
||||
skipRedirect: z.string().optional().transform((val) => val === "true"),
|
||||
});
|
||||
|
||||
const SkipOnboardingFormSchema = z.object({
|
||||
action: z.literal("skip-onboarding"),
|
||||
});
|
||||
|
||||
const SelectVercelProjectFormSchema = z.object({
|
||||
action: z.literal("select-vercel-project"),
|
||||
vercelProjectId: z.string().min(1, "Please select a Vercel project"),
|
||||
vercelProjectName: z.string().min(1),
|
||||
});
|
||||
|
||||
const UpdateEnvMappingFormSchema = z.object({
|
||||
action: z.literal("update-env-mapping"),
|
||||
vercelStagingEnvironment: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const DisableAutoAssignFormSchema = z.object({
|
||||
action: z.literal("disable-auto-assign"),
|
||||
});
|
||||
|
||||
const VercelActionSchema = z.discriminatedUnion("action", [
|
||||
UpdateVercelConfigFormSchema,
|
||||
DisconnectVercelFormSchema,
|
||||
CompleteOnboardingFormSchema,
|
||||
SkipOnboardingFormSchema,
|
||||
SelectVercelProjectFormSchema,
|
||||
UpdateEnvMappingFormSchema,
|
||||
DisableAutoAssignFormSchema,
|
||||
]);
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new VercelSettingsPresenter();
|
||||
const resultOrFail = await presenter.call({
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
logger.error("Failed to load Vercel settings", {
|
||||
url: request.url,
|
||||
params,
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
throw new Response("Failed to load Vercel settings", { status: 500 });
|
||||
}
|
||||
|
||||
const result = resultOrFail.value;
|
||||
const url = new URL(request.url);
|
||||
const needsOnboarding = url.searchParams.get("vercelOnboarding") === "true";
|
||||
const vercelEnvironmentId = url.searchParams.get("vercelEnvironmentId") || undefined;
|
||||
|
||||
let onboardingData: VercelOnboardingData | null = null;
|
||||
if (needsOnboarding) {
|
||||
onboardingData = await presenter.getOnboardingData(
|
||||
project.id,
|
||||
project.organizationId,
|
||||
vercelEnvironmentId
|
||||
);
|
||||
}
|
||||
|
||||
const authInvalid = onboardingData?.authInvalid || result.authInvalid || false;
|
||||
|
||||
return typedjson({
|
||||
...result,
|
||||
authInvalid,
|
||||
onboardingData,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: VercelActionSchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const settingsPath = v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
);
|
||||
|
||||
const vercelService = new VercelIntegrationService();
|
||||
const { action: actionType } = submission.value;
|
||||
|
||||
switch (actionType) {
|
||||
case "update-config": {
|
||||
const {
|
||||
atomicBuilds,
|
||||
pullEnvVarsBeforeBuild,
|
||||
discoverEnvVars,
|
||||
vercelStagingEnvironment,
|
||||
} = submission.value;
|
||||
|
||||
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
|
||||
|
||||
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
|
||||
atomicBuilds,
|
||||
pullEnvVarsBeforeBuild,
|
||||
discoverEnvVars,
|
||||
vercelStagingEnvironment: parsedStagingEnv,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully");
|
||||
}
|
||||
|
||||
return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings");
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
const success = await vercelService.disconnectVercelProject(project.id);
|
||||
|
||||
if (success) {
|
||||
return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected");
|
||||
}
|
||||
|
||||
return redirectWithErrorMessage(settingsPath, request, "Failed to disconnect Vercel project");
|
||||
}
|
||||
|
||||
case "complete-onboarding": {
|
||||
const {
|
||||
vercelStagingEnvironment,
|
||||
pullEnvVarsBeforeBuild,
|
||||
atomicBuilds,
|
||||
discoverEnvVars,
|
||||
syncEnvVarsMapping,
|
||||
next,
|
||||
skipRedirect,
|
||||
} = submission.value;
|
||||
|
||||
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
|
||||
const parsedSyncEnvVarsMapping = syncEnvVarsMapping
|
||||
? safeJsonParse(syncEnvVarsMapping).unwrapOr(undefined) as SyncEnvVarsMapping | undefined
|
||||
: undefined;
|
||||
|
||||
const result = await vercelService.completeOnboarding(project.id, {
|
||||
vercelStagingEnvironment: parsedStagingEnv,
|
||||
pullEnvVarsBeforeBuild,
|
||||
atomicBuilds,
|
||||
discoverEnvVars,
|
||||
syncEnvVarsMapping: parsedSyncEnvVarsMapping,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (skipRedirect) {
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
if (next) {
|
||||
const sanitizedNext = sanitizeVercelNextUrl(next);
|
||||
if (sanitizedNext) {
|
||||
return json({ success: true, redirectTo: sanitizedNext });
|
||||
}
|
||||
logger.warn("Rejected next URL - not same-origin or vercel.com", { next });
|
||||
}
|
||||
|
||||
return json({ success: true, redirectTo: settingsPath });
|
||||
}
|
||||
|
||||
return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup");
|
||||
}
|
||||
|
||||
case "update-env-mapping": {
|
||||
const { vercelStagingEnvironment } = submission.value;
|
||||
|
||||
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
|
||||
|
||||
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
|
||||
vercelStagingEnvironment: parsedStagingEnv,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
return json({ success: false, error: "Failed to update environment mapping" }, { status: 400 });
|
||||
}
|
||||
|
||||
case "skip-onboarding": {
|
||||
return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped");
|
||||
}
|
||||
|
||||
case "select-vercel-project": {
|
||||
const { vercelProjectId, vercelProjectName } = submission.value;
|
||||
|
||||
const selectResult = await fromPromise(
|
||||
vercelService.selectVercelProject({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
vercelProjectId,
|
||||
vercelProjectName,
|
||||
userId,
|
||||
}),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (selectResult.isErr()) {
|
||||
logger.error("Failed to select Vercel project", { error: selectResult.error });
|
||||
return json({
|
||||
error: "Failed to connect Vercel project. Please try again.",
|
||||
});
|
||||
}
|
||||
|
||||
const { integration, syncResult } = selectResult.value;
|
||||
|
||||
if (!syncResult.success && syncResult.errors.length > 0) {
|
||||
logger.warn("Failed to send trigger secrets to Vercel", {
|
||||
projectId: project.id,
|
||||
vercelProjectId,
|
||||
errors: syncResult.errors,
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
integrationId: integration.id,
|
||||
syncErrors: syncResult.errors,
|
||||
});
|
||||
}
|
||||
|
||||
case "disable-auto-assign": {
|
||||
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(
|
||||
project.id
|
||||
);
|
||||
|
||||
if (!orgIntegration) {
|
||||
return redirectWithErrorMessage(settingsPath, request, "No Vercel integration found");
|
||||
}
|
||||
|
||||
const projectIntegration = await vercelService.getVercelProjectIntegration(project.id);
|
||||
|
||||
if (!projectIntegration) {
|
||||
return redirectWithErrorMessage(settingsPath, request, "No Vercel project connected");
|
||||
}
|
||||
|
||||
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
||||
|
||||
const disableResult = await VercelIntegrationRepository.getVercelClient(orgIntegration)
|
||||
.andThen((client) =>
|
||||
VercelIntegrationRepository.disableAutoAssignCustomDomains(
|
||||
client,
|
||||
projectIntegration.parsedIntegrationData.vercelProjectId,
|
||||
teamId
|
||||
)
|
||||
);
|
||||
|
||||
if (disableResult.isErr()) {
|
||||
logger.error("Failed to disable auto-assign custom domains", { error: disableResult.error });
|
||||
return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains");
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(settingsPath, request, "Auto-assign custom domains disabled");
|
||||
}
|
||||
|
||||
default: {
|
||||
submission.value satisfies never;
|
||||
return redirectBackWithErrorMessage(request, "Failed to process request");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function VercelConnectionPrompt({
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
hasOrgIntegration,
|
||||
isGitHubConnected,
|
||||
onOpenModal,
|
||||
isLoading,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
hasOrgIntegration: boolean;
|
||||
isGitHubConnected: boolean;
|
||||
onOpenModal?: () => void;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const installPath = vercelAppInstallPath(organizationSlug, projectSlug);
|
||||
|
||||
const handleConnectProject = () => {
|
||||
if (onOpenModal) {
|
||||
onOpenModal();
|
||||
}
|
||||
};
|
||||
|
||||
const isLoadingProjects = isLoading ?? false;
|
||||
const isDisabled = isLoadingProjects || !onOpenModal;
|
||||
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{hasOrgIntegration ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
onClick={handleConnectProject}
|
||||
disabled={isDisabled}
|
||||
LeadingIcon={
|
||||
isLoadingProjects
|
||||
? () => <SpinnerWhite className="size-4" />
|
||||
: () => <VercelLogo className="size-4 -mx-1" />
|
||||
}
|
||||
>
|
||||
{isLoadingProjects ? "Loading projects..." : "Connect Vercel project"}
|
||||
</Button>
|
||||
<span className="flex items-center gap-1 text-xs text-text-dimmed">
|
||||
<CheckCircleIcon className="size-4 text-success" /> Vercel app is installed
|
||||
</span>
|
||||
{!onOpenModal && (
|
||||
<span className="text-xs text-amber-400">
|
||||
Please reconnect Vercel to continue
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LinkButton
|
||||
to={installPath}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={() => <VercelLogo className="size-4 -mx-1" />}
|
||||
>
|
||||
Install Vercel app
|
||||
</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function VercelAuthInvalidBanner({
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const installUrl = vercelAppInstallPath(organizationSlug, projectSlug);
|
||||
|
||||
return (
|
||||
<Callout variant="error" className="mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-sans text-sm font-medium text-text-bright mb-2">
|
||||
Vercel connection expired
|
||||
</p>
|
||||
<p className="font-sans text-xs text-text-dimmed mb-3">
|
||||
Your Vercel access token has expired or been revoked. Please reconnect to restore functionality.
|
||||
</p>
|
||||
<LinkButton
|
||||
to={installUrl}
|
||||
variant="minimal/small"
|
||||
className="bg-error/10 hover:bg-error/20 text-error border-error/20"
|
||||
>
|
||||
Reconnect Vercel
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
function VercelGitHubWarning() {
|
||||
return (
|
||||
<Callout variant="warning" className="mb-4">
|
||||
<p className="font-sans text-xs font-normal text-text-dimmed">
|
||||
GitHub integration is not connected. Vercel integration cannot sync environment variables and
|
||||
link deployments without a properly installed GitHub integration.
|
||||
</p>
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
function envSlugLabel(slug: EnvSlug): string {
|
||||
switch (slug) {
|
||||
case "prod":
|
||||
return "Production";
|
||||
case "stg":
|
||||
return "Staging";
|
||||
case "preview":
|
||||
return "Preview";
|
||||
case "dev":
|
||||
return "Development";
|
||||
}
|
||||
}
|
||||
|
||||
function ConnectedVercelProjectForm({
|
||||
connectedProject,
|
||||
hasStagingEnvironment,
|
||||
hasPreviewEnvironment,
|
||||
customEnvironments,
|
||||
autoAssignCustomDomains,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
connectedProject: ConnectedVercelProject;
|
||||
hasStagingEnvironment: boolean;
|
||||
hasPreviewEnvironment: boolean;
|
||||
customEnvironments: Array<{ id: string; slug: string }>;
|
||||
autoAssignCustomDomains: boolean | null;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasConfigChanges, setHasConfigChanges] = useState(false);
|
||||
const [configValues, setConfigValues] = useState({
|
||||
atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [],
|
||||
pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [],
|
||||
discoverEnvVars: connectedProject.integrationData.config.discoverEnvVars ?? [],
|
||||
vercelStagingEnvironment:
|
||||
connectedProject.integrationData.config.vercelStagingEnvironment ?? null,
|
||||
});
|
||||
|
||||
const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? [];
|
||||
const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [];
|
||||
const originalDiscoverEnvVars = connectedProject.integrationData.config.discoverEnvVars ?? [];
|
||||
const originalStagingEnv = connectedProject.integrationData.config.vercelStagingEnvironment ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const atomicBuildsChanged =
|
||||
JSON.stringify([...configValues.atomicBuilds].sort()) !==
|
||||
JSON.stringify([...originalAtomicBuilds].sort());
|
||||
const pullEnvVarsChanged =
|
||||
JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !==
|
||||
JSON.stringify([...originalPullEnvVars].sort());
|
||||
const discoverEnvVarsChanged =
|
||||
JSON.stringify([...configValues.discoverEnvVars].sort()) !==
|
||||
JSON.stringify([...originalDiscoverEnvVars].sort());
|
||||
const stagingEnvChanged = configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId;
|
||||
|
||||
setHasConfigChanges(atomicBuildsChanged || pullEnvVarsChanged || discoverEnvVarsChanged || stagingEnvChanged);
|
||||
}, [configValues, originalAtomicBuilds, originalPullEnvVars, originalDiscoverEnvVars, originalStagingEnv]);
|
||||
|
||||
const [configForm, fields] = useForm({
|
||||
id: "update-vercel-config",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateVercelConfigFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isConfigLoading =
|
||||
navigation.formData?.get("action") === "update-config" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug);
|
||||
|
||||
const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment);
|
||||
const availableEnvSlugsForBuildSettings = getAvailableEnvSlugsForBuildSettings(hasStagingEnvironment, hasPreviewEnvironment);
|
||||
|
||||
const formatSelectedEnvs = (selected: EnvSlug[], availableSlugs: EnvSlug[] = availableEnvSlugs): string => {
|
||||
if (selected.length === 0) return "None selected";
|
||||
if (selected.length === availableSlugs.length) return "All environments";
|
||||
return selected.map(envSlugLabel).join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<VercelLogo className="size-4" />
|
||||
<span className="max-w-52 truncate text-sm text-text-bright">
|
||||
{connectedProject.vercelProjectName}
|
||||
</span>
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime
|
||||
date={connectedProject.createdAt}
|
||||
includeTime={false}
|
||||
includeSeconds={false}
|
||||
showTimezone={false}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="minimal/small">Disconnect</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Disconnect Vercel project</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph className="mb-1">
|
||||
Are you sure you want to disconnect{" "}
|
||||
<span className="font-semibold">{connectedProject.vercelProjectName}</span>?
|
||||
This will stop pulling environment variables and disable atomic deployments.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post" action={actionUrl}>
|
||||
<input type="hidden" name="action" value="disconnect" />
|
||||
<Button type="submit" variant="danger/medium">
|
||||
Disconnect project
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Configuration form */}
|
||||
<Form method="post" action={actionUrl} {...configForm.props}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="atomicBuilds"
|
||||
value={JSON.stringify(configValues.atomicBuilds)}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="pullEnvVarsBeforeBuild"
|
||||
value={JSON.stringify(configValues.pullEnvVarsBeforeBuild)}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="discoverEnvVars"
|
||||
value={JSON.stringify(configValues.discoverEnvVars)}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="vercelStagingEnvironment"
|
||||
value={configValues.vercelStagingEnvironment ? JSON.stringify(configValues.vercelStagingEnvironment) : ""}
|
||||
/>
|
||||
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Staging environment mapping */}
|
||||
{hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && (
|
||||
<div>
|
||||
<Label>Map Vercel environment to Staging</Label>
|
||||
<Hint className="mb-2">
|
||||
Select which custom Vercel environment should map to Trigger.dev's Staging
|
||||
environment.
|
||||
</Hint>
|
||||
<Select
|
||||
value={configValues.vercelStagingEnvironment?.environmentId || ""}
|
||||
setValue={(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
const env = customEnvironments?.find((e) => e.id === value);
|
||||
setConfigValues((prev) => ({
|
||||
...prev,
|
||||
vercelStagingEnvironment: env
|
||||
? { environmentId: env.id, displayName: env.slug }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
items={[{ id: "", slug: "None" }, ...customEnvironments]}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select environment"
|
||||
dropdownIcon
|
||||
text={configValues.vercelStagingEnvironment?.displayName || "None"}
|
||||
>
|
||||
{[
|
||||
<SelectItem key="" value="">
|
||||
None
|
||||
</SelectItem>,
|
||||
...customEnvironments.map((env) => (
|
||||
<SelectItem key={env.id} value={env.id}>
|
||||
{env.slug}
|
||||
</SelectItem>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BuildSettingsFields
|
||||
availableEnvSlugs={availableEnvSlugsForBuildSettings}
|
||||
pullEnvVarsBeforeBuild={configValues.pullEnvVarsBeforeBuild}
|
||||
onPullEnvVarsChange={(slugs) =>
|
||||
setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs }))
|
||||
}
|
||||
discoverEnvVars={configValues.discoverEnvVars}
|
||||
onDiscoverEnvVarsChange={(slugs) =>
|
||||
setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs }))
|
||||
}
|
||||
atomicBuilds={configValues.atomicBuilds}
|
||||
onAtomicBuildsChange={(slugs) =>
|
||||
setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs }))
|
||||
}
|
||||
envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`}
|
||||
/>
|
||||
|
||||
{/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */}
|
||||
{autoAssignCustomDomains !== false &&
|
||||
configValues.atomicBuilds.includes("prod") && (
|
||||
<Callout variant="warning">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-sans text-xs font-normal text-text-dimmed">
|
||||
Atomic deployments require the "Auto-assign Custom Domains" setting to be
|
||||
disabled on your Vercel project. Without this, Vercel will promote
|
||||
deployments before Trigger.dev is ready.
|
||||
</p>
|
||||
<Form method="post" action={actionUrl}>
|
||||
<input type="hidden" name="action" value="disable-auto-assign" />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="tertiary/small"
|
||||
disabled={
|
||||
navigation.formData?.get("action") === "disable-auto-assign" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading")
|
||||
}
|
||||
LeadingIcon={
|
||||
navigation.formData?.get("action") === "disable-auto-assign" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading")
|
||||
? SpinnerWhite
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Disable auto-assign custom domains
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormError>{configForm.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-config"
|
||||
variant="secondary/small"
|
||||
disabled={isConfigLoading || !hasConfigChanges}
|
||||
LeadingIcon={isConfigLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VercelSettingsPanel({
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
onOpenVercelModal,
|
||||
isLoadingVercelData,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
onOpenVercelModal?: () => void;
|
||||
isLoadingVercelData?: boolean;
|
||||
}) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const location = useLocation();
|
||||
const data = fetcher.data;
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.authInvalid && !hasError && !data && !hasFetched) {
|
||||
fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug));
|
||||
setHasFetched(true);
|
||||
}
|
||||
}, [organizationSlug, projectSlug, environmentSlug, data?.authInvalid, hasError, data, hasFetched]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFetched && fetcher.state === "idle" && fetcher.data === undefined && !hasError) {
|
||||
setHasError(true);
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, hasError, hasFetched]);
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="rounded-sm border border-rose-500/40 bg-rose-500/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-rose-400">Failed to load Vercel settings</p>
|
||||
<p className="text-sm text-rose-300 mt-1">
|
||||
There was an error loading the Vercel integration settings. Please refresh the page to try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetcher.state === "loading" && !data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-text-dimmed">
|
||||
<SpinnerWhite className="size-4" />
|
||||
<span className="text-sm">Loading Vercel settings...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showGitHubWarning = data.connectedProject && !data.isGitHubConnected;
|
||||
const showAuthInvalid = data.authInvalid || data.onboardingData?.authInvalid;
|
||||
|
||||
if (data.connectedProject) {
|
||||
return (
|
||||
<>
|
||||
{showAuthInvalid && <VercelAuthInvalidBanner organizationSlug={organizationSlug} projectSlug={projectSlug} />}
|
||||
{showGitHubWarning && <VercelGitHubWarning />}
|
||||
{!showAuthInvalid && (<ConnectedVercelProjectForm
|
||||
connectedProject={data.connectedProject}
|
||||
hasStagingEnvironment={data.hasStagingEnvironment}
|
||||
hasPreviewEnvironment={data.hasPreviewEnvironment}
|
||||
customEnvironments={data.customEnvironments}
|
||||
autoAssignCustomDomains={data.autoAssignCustomDomains ?? null}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
/>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{showAuthInvalid && <VercelAuthInvalidBanner organizationSlug={organizationSlug} projectSlug={projectSlug} />}
|
||||
{!showAuthInvalid && (
|
||||
<>
|
||||
<VercelConnectionPrompt
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
hasOrgIntegration={data.hasOrgIntegration}
|
||||
isGitHubConnected={data.isGitHubConnected}
|
||||
onOpenModal={showAuthInvalid ? undefined : onOpenVercelModal}
|
||||
isLoading={isLoadingVercelData}
|
||||
/>
|
||||
<Hint>
|
||||
{data.hasOrgIntegration
|
||||
? "Connect your Vercel project to pull environment variables and trigger builds automatically."
|
||||
: "Install the Vercel app to connect your projects and pull environment variables."}
|
||||
</Hint>
|
||||
{!data.isGitHubConnected && (
|
||||
<Hint>
|
||||
GitHub integration is not connected. Vercel integration cannot sync environment variables and
|
||||
link deployments without a properly installed GitHub integration.
|
||||
</Hint>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
import { VercelOnboardingModal } from "~/components/integrations/VercelOnboardingModal";
|
||||
|
||||
export { VercelSettingsPanel, VercelOnboardingModal };
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
import { setReferralSourceCookie } from "~/services/referralSource.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
|
||||
|
||||
const VercelCallbackSchema = z
|
||||
.object({
|
||||
code: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
error_description: z.string().optional(),
|
||||
configurationId: z.string().optional(),
|
||||
next: z.string().optional()
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "GET") {
|
||||
throw new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) {
|
||||
const currentUrl = new URL(request.url);
|
||||
const redirectTo = `${currentUrl.pathname}${currentUrl.search}`;
|
||||
const referralCookie = await setReferralSourceCookie("vercel");
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", referralCookie);
|
||||
|
||||
throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, { headers });
|
||||
}
|
||||
|
||||
const url = requestUrl(request);
|
||||
const parsed = VercelCallbackSchema.safeParse(Object.fromEntries(url.searchParams));
|
||||
|
||||
if (!parsed.success) {
|
||||
logger.error("Invalid Vercel callback params", { error: parsed.error });
|
||||
throw new Response("Invalid callback parameters", { status: 400 });
|
||||
}
|
||||
|
||||
const { code, state, error, error_description, configurationId, next: rawNextUrl } = parsed.data;
|
||||
|
||||
// Sanitize the `next` parameter to prevent open redirects
|
||||
const nextUrl = sanitizeVercelNextUrl(rawNextUrl);
|
||||
|
||||
if (error) {
|
||||
logger.error("Vercel OAuth error", { error, error_description });
|
||||
throw new Response("Vercel OAuth error", { status: 500 });
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
logger.error("Missing authorization code from Vercel callback");
|
||||
throw new Response("Missing authorization code", { status: 400 });
|
||||
}
|
||||
|
||||
// Route with state: dashboard-invoked flow
|
||||
if (state) {
|
||||
const params = new URLSearchParams({ state, code, origin: "dashboard" });
|
||||
if (configurationId) params.set("configurationId", configurationId);
|
||||
if (nextUrl) params.set("next", nextUrl);
|
||||
return redirect(`/vercel/connect?${params.toString()}`);
|
||||
}
|
||||
|
||||
// Route without state but with configurationId: marketplace-invoked flow
|
||||
if (configurationId) {
|
||||
const params = new URLSearchParams({ code, configurationId, origin: "marketplace" });
|
||||
if (nextUrl) params.set("next", nextUrl);
|
||||
return redirect(`/vercel/onboarding?${params.toString()}`);
|
||||
}
|
||||
|
||||
logger.error("Missing both state and configurationId from Vercel callback");
|
||||
throw new Response("Missing state or configurationId parameter", { status: 400 });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationVercelIntegrationPath } from "~/utils/pathBuilder";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
configurationId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Endpoint to handle Vercel integration configuration request coming from marketplace
|
||||
*/
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
await requireUserId(request);
|
||||
const url = new URL(request.url);
|
||||
const searchParams = Object.fromEntries(url.searchParams);
|
||||
|
||||
const { configurationId } = SearchParamsSchema.parse(searchParams);
|
||||
|
||||
// Find the organization integration by configurationId (installationId in integrationData)
|
||||
const integration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
integrationData: {
|
||||
path: ["installationId"],
|
||||
equals: configurationId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new Response("Integration not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Redirect to the organization's Vercel integration page
|
||||
return redirect(organizationVercelIntegrationPath(integration.organization));
|
||||
};
|
||||
|
||||
// This route doesn't render anything, it just redirects
|
||||
export default function VercelConfigurePage() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "@remix-run/server-runtime";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercelIntegration.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
|
||||
import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
|
||||
const VercelConnectSchema = z.object({
|
||||
state: z.string(),
|
||||
configurationId: z.string().optional(),
|
||||
code: z.string(),
|
||||
next: z.string().optional(),
|
||||
origin: z.enum(["marketplace", "dashboard"]),
|
||||
});
|
||||
|
||||
async function createOrFindVercelIntegration(
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
tokenResponse: TokenResponse,
|
||||
configurationId: string | undefined,
|
||||
origin: 'marketplace' | 'dashboard'
|
||||
): Promise<void> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { organization: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId(
|
||||
organizationId,
|
||||
tokenResponse.teamId ?? null
|
||||
);
|
||||
|
||||
if (orgIntegration) {
|
||||
await VercelIntegrationRepository.updateVercelOrgIntegrationToken({
|
||||
integrationId: orgIntegration.id,
|
||||
accessToken: tokenResponse.accessToken,
|
||||
tokenType: tokenResponse.tokenType,
|
||||
teamId: tokenResponse.teamId ?? null,
|
||||
userId: tokenResponse.userId,
|
||||
installationId: configurationId,
|
||||
raw: tokenResponse.raw
|
||||
});
|
||||
} else {
|
||||
await VercelIntegrationRepository.createVercelOrgIntegration({
|
||||
accessToken: tokenResponse.accessToken,
|
||||
tokenType: tokenResponse.tokenType,
|
||||
teamId: tokenResponse.teamId ?? null,
|
||||
userId: tokenResponse.userId,
|
||||
installationId: configurationId,
|
||||
organization: project.organization,
|
||||
raw: tokenResponse.raw,
|
||||
origin,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const url = requestUrl(request);
|
||||
|
||||
const parsed = VercelConnectSchema.safeParse(Object.fromEntries(url.searchParams));
|
||||
if (!parsed.success) {
|
||||
logger.error("Invalid Vercel connect params", { error: parsed.error });
|
||||
throw new Response("Invalid parameters", { status: 400 });
|
||||
}
|
||||
|
||||
const { state, configurationId, code, next, origin } = parsed.data;
|
||||
|
||||
const validationResult = await validateVercelOAuthState(state);
|
||||
if (!validationResult.ok) {
|
||||
logger.error("Invalid Vercel OAuth state JWT", { error: validationResult.error });
|
||||
|
||||
if (
|
||||
validationResult.error?.includes("expired") ||
|
||||
validationResult.error?.includes("Token has expired")
|
||||
) {
|
||||
const params = new URLSearchParams({ error: "expired" });
|
||||
return redirect(`/vercel/onboarding?${params.toString()}`);
|
||||
}
|
||||
|
||||
throw new Response("Invalid state", { status: 400 });
|
||||
}
|
||||
|
||||
const stateData = validationResult.state;
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
id: stateData.projectId,
|
||||
organizationId: stateData.organizationId,
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
members: {
|
||||
some: { userId },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
logger.error("Project not found or access denied", {
|
||||
projectId: stateData.projectId,
|
||||
userId,
|
||||
});
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const tokenResult = await VercelIntegrationRepository.exchangeCodeForToken(code);
|
||||
if (tokenResult.isErr()) {
|
||||
const params = new URLSearchParams({ error: "expired" });
|
||||
return redirect(`/vercel/onboarding?${params.toString()}`);
|
||||
}
|
||||
const tokenResponse = tokenResult.value;
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
slug: stateData.environmentSlug,
|
||||
archivedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
logger.error("Environment not found", {
|
||||
projectId: project.id,
|
||||
environmentSlug: stateData.environmentSlug,
|
||||
});
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const settingsPath = v3ProjectSettingsPath(
|
||||
{ slug: stateData.organizationSlug },
|
||||
{ slug: stateData.projectSlug },
|
||||
{ slug: environment.slug }
|
||||
);
|
||||
|
||||
const result = await fromPromise(
|
||||
createOrFindVercelIntegration(stateData.organizationId, stateData.projectId, tokenResponse, configurationId, origin),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (result.isErr()) {
|
||||
logger.error("Failed to complete Vercel integration", { error: result.error });
|
||||
throw redirect(settingsPath);
|
||||
}
|
||||
|
||||
logger.info("Vercel organization integration created successfully", {
|
||||
organizationId: stateData.organizationId,
|
||||
projectId: stateData.projectId,
|
||||
teamId: tokenResponse.teamId,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({ vercelOnboarding: "true", origin });
|
||||
if (next) {
|
||||
params.set("next", next);
|
||||
}
|
||||
|
||||
return redirect(`${settingsPath}?${params.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
|
||||
const QuerySchema = z.object({
|
||||
org_slug: z.string(),
|
||||
project_slug: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
|
||||
|
||||
if (!parsed.success) {
|
||||
logger.warn("Vercel App installation redirect with invalid params", {
|
||||
searchParams,
|
||||
error: parsed.error,
|
||||
});
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
const { org_slug, project_slug } = parsed.data;
|
||||
const user = await requireUser(request);
|
||||
|
||||
// Find the organization
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
// Find the project
|
||||
const project = await findProjectBySlug(org_slug, project_slug, user.id);
|
||||
if (!project) {
|
||||
logger.warn("Vercel App installation attempt for non-existent project", {
|
||||
org_slug,
|
||||
project_slug,
|
||||
userId: user.id,
|
||||
});
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
// Use "prod" as the default environment slug for the redirect
|
||||
// The callback will redirect to the settings page for this environment
|
||||
const environmentSlug = "prod";
|
||||
|
||||
// Generate JWT state token
|
||||
const stateToken = await generateVercelOAuthState({
|
||||
organizationId: org.id,
|
||||
projectId: project.id,
|
||||
environmentSlug,
|
||||
organizationSlug: org_slug,
|
||||
projectSlug: project_slug,
|
||||
});
|
||||
|
||||
// Generate Vercel install URL
|
||||
const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken);
|
||||
|
||||
return redirect(vercelInstallUrl);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json, redirect } from "@remix-run/server-runtime";
|
||||
import { fromPromise } from "neverthrow";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { BuildingOfficeIcon, FolderIcon } from "@heroicons/react/20/solid";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { ButtonSpinner } from "~/components/primitives/Spinner";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
|
||||
|
||||
const LoaderParamsSchema = z.object({
|
||||
organizationId: z.string().optional().nullable(),
|
||||
code: z.string().optional().nullable(),
|
||||
configurationId: z.string().optional().nullable(),
|
||||
next: z.string().optional().nullable(),
|
||||
error: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const SelectOrgActionSchema = z.object({
|
||||
action: z.literal("select-org"),
|
||||
organizationId: z.string(),
|
||||
code: z.string(),
|
||||
configurationId: z.string().optional().nullable(),
|
||||
next: z.string().optional(),
|
||||
});
|
||||
|
||||
const SelectProjectActionSchema = z.object({
|
||||
action: z.literal("select-project"),
|
||||
projectId: z.string(),
|
||||
organizationId: z.string(),
|
||||
code: z.string(),
|
||||
configurationId: z.string().optional().nullable(),
|
||||
next: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const ActionSchema = z.discriminatedUnion("action", [
|
||||
SelectOrgActionSchema,
|
||||
SelectProjectActionSchema,
|
||||
]);
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const url = new URL(request.url);
|
||||
|
||||
const params = LoaderParamsSchema.safeParse({
|
||||
organizationId: url.searchParams.get("organizationId"),
|
||||
code: url.searchParams.get("code"),
|
||||
configurationId: url.searchParams.get("configurationId"),
|
||||
next: url.searchParams.get("next"),
|
||||
error: url.searchParams.get("error"),
|
||||
});
|
||||
|
||||
if (!params.success) {
|
||||
logger.error("Invalid params for Vercel onboarding", { error: params.error });
|
||||
throw redirectWithErrorMessage(
|
||||
"/",
|
||||
request,
|
||||
"Invalid installation parameters. Please try again from Vercel."
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = params.data;
|
||||
if (error === "expired") {
|
||||
return typedjson({
|
||||
step: "error" as const,
|
||||
error: "Your installation session has expired. Please start the installation again.",
|
||||
code: params.data.code ?? null,
|
||||
configurationId: params.data.configurationId ?? null,
|
||||
next: params.data.next ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!params.data.code) {
|
||||
logger.error("Missing code parameter for Vercel onboarding");
|
||||
throw redirectWithErrorMessage(
|
||||
"/",
|
||||
request,
|
||||
"Invalid installation parameters. Please try again from Vercel."
|
||||
);
|
||||
}
|
||||
|
||||
const code = params.data.code;
|
||||
|
||||
const organizations = await prisma.organization.findMany({
|
||||
where: {
|
||||
members: {
|
||||
some: { userId },
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
projects: {
|
||||
where: {
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// New user: no organizations
|
||||
if (organizations.length === 0) {
|
||||
const onboardingParams = new URLSearchParams();
|
||||
onboardingParams.set("code", code);
|
||||
if (params.data.configurationId) {
|
||||
onboardingParams.set("configurationId", params.data.configurationId);
|
||||
}
|
||||
onboardingParams.set("integration", "vercel");
|
||||
if (params.data.next) {
|
||||
onboardingParams.set("next", params.data.next);
|
||||
}
|
||||
throw redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`);
|
||||
}
|
||||
|
||||
// If organizationId is provided, show project selection
|
||||
if (params.data.organizationId) {
|
||||
const organization = organizations.find((org) => org.id === params.data.organizationId);
|
||||
|
||||
if (!organization) {
|
||||
logger.error("Organization not found or access denied", {
|
||||
organizationId: params.data.organizationId,
|
||||
userId,
|
||||
});
|
||||
throw redirectWithErrorMessage(
|
||||
"/",
|
||||
request,
|
||||
"Organization not found. Please try again."
|
||||
);
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
step: "project" as const,
|
||||
organization,
|
||||
organizations,
|
||||
code: code,
|
||||
configurationId: params.data.configurationId ?? null,
|
||||
next: params.data.next ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
step: "org" as const,
|
||||
organizations,
|
||||
code: code,
|
||||
configurationId: params.data.configurationId ?? null,
|
||||
next: params.data.next ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const formData = await request.formData();
|
||||
|
||||
const submission = ActionSchema.safeParse({
|
||||
action: formData.get("action"),
|
||||
organizationId: formData.get("organizationId"),
|
||||
projectId: formData.get("projectId"),
|
||||
code: formData.get("code"),
|
||||
configurationId: formData.get("configurationId"),
|
||||
next: formData.get("next"),
|
||||
});
|
||||
|
||||
if (!submission.success) {
|
||||
return json({ error: "Invalid submission" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { code, configurationId, next } = submission.data;
|
||||
|
||||
// Handle org selection
|
||||
if (submission.data.action === "select-org") {
|
||||
const { organizationId } = submission.data;
|
||||
|
||||
const projectParams = new URLSearchParams();
|
||||
projectParams.set("organizationId", organizationId);
|
||||
projectParams.set("code", code);
|
||||
if (configurationId) {
|
||||
projectParams.set("configurationId", configurationId);
|
||||
}
|
||||
if (next) {
|
||||
projectParams.set("next", next);
|
||||
}
|
||||
|
||||
return redirect(`/vercel/onboarding?${projectParams.toString()}`);
|
||||
}
|
||||
|
||||
// Handle project selection
|
||||
const { projectId, organizationId } = submission.data;
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId,
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
logger.error("Project not found or access denied", { projectId, userId });
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
slug: "prod",
|
||||
archivedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
logger.error("Environment not found", { projectId: project.id });
|
||||
return json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const stateResult = await fromPromise(
|
||||
generateVercelOAuthState({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentSlug: environment.slug,
|
||||
organizationSlug: project.organization.slug,
|
||||
projectSlug: project.slug,
|
||||
}),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (stateResult.isErr()) {
|
||||
logger.error("Failed to generate Vercel OAuth state", { error: stateResult.error });
|
||||
return json({ error: "Failed to generate installation state" }, { status: 500 });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("state", stateResult.value);
|
||||
params.set("code", code);
|
||||
if (configurationId) {
|
||||
params.set("configurationId", configurationId);
|
||||
}
|
||||
params.set("origin", "marketplace");
|
||||
if (next) {
|
||||
params.set("next", next);
|
||||
}
|
||||
|
||||
return redirect(`/vercel/connect?${params.toString()}`, 303);
|
||||
}
|
||||
|
||||
export default function VercelOnboardingPage() {
|
||||
const data = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
// Reset isInstalling when navigation returns to idle (e.g. on error)
|
||||
useEffect(() => {
|
||||
if (navigation.state === "idle" && isInstalling) {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [navigation.state, isInstalling]);
|
||||
|
||||
if (data.step === "error") {
|
||||
return (
|
||||
<AppContainer className="bg-charcoal-900">
|
||||
<BackgroundWrapper>
|
||||
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<FormTitle title="Installation Expired" description={data.error} />
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={() => window.close()}
|
||||
className="w-full"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</MainCenteredContainer>
|
||||
</BackgroundWrapper>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.step === "org") {
|
||||
const newOrgUrl = (() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("code", data.code);
|
||||
if (data.configurationId) {
|
||||
params.set("configurationId", data.configurationId);
|
||||
}
|
||||
params.set("integration", "vercel");
|
||||
if (data.next) {
|
||||
params.set("next", data.next);
|
||||
}
|
||||
return `/orgs/new?${params.toString()}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<AppContainer className="bg-charcoal-900">
|
||||
<BackgroundWrapper>
|
||||
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<FormTitle
|
||||
LeadingIcon={<BuildingOfficeIcon className="size-7 text-indigo-500" />}
|
||||
title="Select Organization"
|
||||
description="Choose which organization to install the Vercel integration into."
|
||||
/>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="action" value="select-org" />
|
||||
<input type="hidden" name="code" value={data.code} />
|
||||
{data.configurationId && (
|
||||
<input type="hidden" name="configurationId" value={data.configurationId} />
|
||||
)}
|
||||
{data.next && <input type="hidden" name="next" value={data.next} />}
|
||||
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label>Organization</Label>
|
||||
<Select
|
||||
name="organizationId"
|
||||
placeholder="Choose an organization"
|
||||
required
|
||||
variant="tertiary/medium"
|
||||
dropdownIcon
|
||||
defaultValue={data.organizations[0]?.id}
|
||||
text={(v) =>
|
||||
typeof v === "string"
|
||||
? data.organizations.find((o) => o.id === v)?.title || "Choose an organization"
|
||||
: "Choose an organization"
|
||||
}
|
||||
>
|
||||
{data.organizations.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkButton to={newOrgUrl} variant="tertiary/small">
|
||||
+ New Organization
|
||||
</LinkButton>
|
||||
<Button type="submit" variant="primary/small">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
</BackgroundWrapper>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const newProjectUrl = (() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("code", data.code);
|
||||
if (data.configurationId) {
|
||||
params.set("configurationId", data.configurationId);
|
||||
}
|
||||
params.set("integration", "vercel");
|
||||
params.set("organizationId", data.organization.id);
|
||||
if (data.next) {
|
||||
params.set("next", data.next);
|
||||
}
|
||||
return `${newProjectPath({ slug: data.organization.slug })}?${params.toString()}`;
|
||||
})();
|
||||
|
||||
const isLoading = isSubmitting || isInstalling;
|
||||
|
||||
return (
|
||||
<AppContainer className="bg-charcoal-900">
|
||||
<BackgroundWrapper>
|
||||
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<FormTitle
|
||||
LeadingIcon={<FolderIcon className="size-7 text-indigo-500" />}
|
||||
title="Select Project"
|
||||
description={`Choose which project in "${data.organization.title}" to install the Vercel integration into.`}
|
||||
/>
|
||||
<Form method="post" onSubmit={() => setIsInstalling(true)}>
|
||||
<input type="hidden" name="action" value="select-project" />
|
||||
<input type="hidden" name="organizationId" value={data.organization.id} />
|
||||
<input type="hidden" name="code" value={data.code} />
|
||||
{data.configurationId && (
|
||||
<input type="hidden" name="configurationId" value={data.configurationId} />
|
||||
)}
|
||||
{data.next && <input type="hidden" name="next" value={data.next} />}
|
||||
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label>Project</Label>
|
||||
<Select
|
||||
name="projectId"
|
||||
placeholder="Choose a project"
|
||||
required
|
||||
variant="tertiary/medium"
|
||||
dropdownIcon
|
||||
defaultValue={data.organization.projects[0]?.id}
|
||||
text={(v) =>
|
||||
typeof v === "string"
|
||||
? data.organization.projects.find((p) => p.id === v)?.name || "Choose a project"
|
||||
: "Choose a project"
|
||||
}
|
||||
>
|
||||
{data.organization.projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkButton to={newProjectUrl} variant="tertiary/small" disabled={isLoading}>
|
||||
+ New Project
|
||||
</LinkButton>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/small"
|
||||
disabled={isLoading}
|
||||
TrailingIcon={isLoading ? ButtonSpinner : undefined}
|
||||
>
|
||||
{isLoading ? "Continuing…" : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
</BackgroundWrapper>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { requireUserId } from "./session.server";
|
||||
|
||||
export async function requireOrganization(request: Request, organizationSlug: string) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response("Organization not found", { status: 404 });
|
||||
}
|
||||
|
||||
return { organization, userId };
|
||||
}
|
||||
@@ -10,5 +10,8 @@ export async function postAuthentication({
|
||||
loginMethod: User["authenticationMethod"];
|
||||
isNewUser: boolean;
|
||||
}) {
|
||||
telemetry.user.identify({ user, isNewUser });
|
||||
telemetry.user.identify({
|
||||
user,
|
||||
isNewUser,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createCookie } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
|
||||
const ReferralSourceSchema = z.enum(["vercel"]);
|
||||
|
||||
export type ReferralSource = z.infer<typeof ReferralSourceSchema>;
|
||||
|
||||
// Cookie that persists for 1 hour to track referral source during login flow
|
||||
export const referralSourceCookie = createCookie("referral-source", {
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
});
|
||||
|
||||
export async function getReferralSource(request: Request): Promise<ReferralSource | null> {
|
||||
const cookie = request.headers.get("Cookie");
|
||||
const value = await referralSourceCookie.parse(cookie);
|
||||
const parsed = ReferralSourceSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export async function setReferralSourceCookie(source: ReferralSource): Promise<string> {
|
||||
return referralSourceCookie.serialize(source);
|
||||
}
|
||||
|
||||
export async function clearReferralSourceCookie(): Promise<string> {
|
||||
return referralSourceCookie.serialize("", {
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export async function trackAndClearReferralSource(
|
||||
request: Request,
|
||||
userId: string,
|
||||
headers: Headers
|
||||
): Promise<void> {
|
||||
const referralSource = await getReferralSource(request);
|
||||
if (!referralSource) return;
|
||||
|
||||
headers.append("Set-Cookie", await clearReferralSourceCookie());
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return;
|
||||
|
||||
const userAge = Date.now() - user.createdAt.getTime();
|
||||
if (userAge >= 30 * 1000) return;
|
||||
|
||||
telemetry.user.identify({ user, isNewUser: true, referralSource });
|
||||
}
|
||||
@@ -28,18 +28,32 @@ class Telemetry {
|
||||
}
|
||||
|
||||
user = {
|
||||
identify: ({ user, isNewUser }: { user: User; isNewUser: boolean }) => {
|
||||
identify: ({
|
||||
user,
|
||||
isNewUser,
|
||||
referralSource,
|
||||
}: {
|
||||
user: User;
|
||||
isNewUser: boolean;
|
||||
referralSource?: string;
|
||||
}) => {
|
||||
if (this.#posthogClient) {
|
||||
const properties: Record<string, any> = {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
authenticationMethod: user.authenticationMethod,
|
||||
admin: user.admin,
|
||||
createdAt: user.createdAt,
|
||||
isNewUser,
|
||||
};
|
||||
|
||||
if (referralSource) {
|
||||
properties.referralSource = referralSource;
|
||||
}
|
||||
|
||||
this.#posthogClient.identify({
|
||||
distinctId: user.id,
|
||||
properties: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
authenticationMethod: user.authenticationMethod,
|
||||
admin: user.admin,
|
||||
createdAt: user.createdAt,
|
||||
isNewUser,
|
||||
},
|
||||
properties,
|
||||
});
|
||||
}
|
||||
if (isNewUser) {
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
import type {
|
||||
PrismaClient,
|
||||
OrganizationProjectIntegration,
|
||||
OrganizationIntegration,
|
||||
SecretReference,
|
||||
} from "@trigger.dev/database";
|
||||
import { ResultAsync } from "neverthrow";
|
||||
import { prisma, $transaction } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
|
||||
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
|
||||
import {
|
||||
VercelProjectIntegrationDataSchema,
|
||||
VercelProjectIntegrationData,
|
||||
VercelIntegrationConfig,
|
||||
SyncEnvVarsMapping,
|
||||
TriggerEnvironmentType,
|
||||
EnvSlug,
|
||||
envTypeToSlug,
|
||||
createDefaultVercelIntegrationData,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
export type VercelProjectIntegrationWithParsedData = OrganizationProjectIntegration & {
|
||||
parsedIntegrationData: VercelProjectIntegrationData;
|
||||
};
|
||||
|
||||
export type VercelProjectIntegrationWithData = VercelProjectIntegrationWithParsedData & {
|
||||
organizationIntegration: OrganizationIntegration;
|
||||
};
|
||||
|
||||
export type VercelProjectIntegrationWithProject = VercelProjectIntegrationWithData & {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class VercelIntegrationService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
async getVercelProjectIntegration(
|
||||
projectId: string,
|
||||
): Promise<VercelProjectIntegrationWithData | null> {
|
||||
const integration = await this.#prismaClient.organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
deletedAt: null,
|
||||
organizationIntegration: {
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organizationIntegration: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData);
|
||||
|
||||
if (!parsedData.success) {
|
||||
logger.error("Failed to parse Vercel integration data", {
|
||||
projectId,
|
||||
integrationId: integration.id,
|
||||
error: parsedData.error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...integration,
|
||||
parsedIntegrationData: parsedData.data,
|
||||
};
|
||||
}
|
||||
|
||||
async getConnectedVercelProjects(
|
||||
organizationId: string
|
||||
): Promise<VercelProjectIntegrationWithProject[]> {
|
||||
const integrations = await this.#prismaClient.organizationProjectIntegration.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
organizationIntegration: {
|
||||
organizationId,
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organizationIntegration: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return integrations
|
||||
.map((integration) => {
|
||||
const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData);
|
||||
if (!parsedData.success) {
|
||||
logger.error("Failed to parse Vercel integration data", {
|
||||
integrationId: integration.id,
|
||||
error: parsedData.error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...integration,
|
||||
parsedIntegrationData: parsedData.data,
|
||||
};
|
||||
})
|
||||
.filter((i): i is VercelProjectIntegrationWithProject => i !== null);
|
||||
}
|
||||
|
||||
async createVercelProjectIntegration(params: {
|
||||
organizationIntegrationId: string;
|
||||
projectId: string;
|
||||
vercelProjectId: string;
|
||||
vercelProjectName: string;
|
||||
vercelTeamId: string | null;
|
||||
vercelTeamSlug?: string;
|
||||
installedByUserId?: string;
|
||||
}): Promise<OrganizationProjectIntegration> {
|
||||
const integrationData = createDefaultVercelIntegrationData(
|
||||
params.vercelProjectId,
|
||||
params.vercelProjectName,
|
||||
params.vercelTeamId,
|
||||
params.vercelTeamSlug
|
||||
);
|
||||
|
||||
return this.#prismaClient.organizationProjectIntegration.create({
|
||||
data: {
|
||||
organizationIntegrationId: params.organizationIntegrationId,
|
||||
projectId: params.projectId,
|
||||
externalEntityId: params.vercelProjectId,
|
||||
integrationData: integrationData,
|
||||
installedBy: params.installedByUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async selectVercelProject(params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
vercelProjectId: string;
|
||||
vercelProjectName: string;
|
||||
userId: string;
|
||||
}): Promise<{
|
||||
integration: OrganizationProjectIntegration;
|
||||
syncResult: { success: boolean; errors: string[] };
|
||||
}> {
|
||||
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByOrganization(
|
||||
params.organizationId
|
||||
);
|
||||
|
||||
if (!orgIntegration) {
|
||||
throw new Error("No Vercel organization integration found");
|
||||
}
|
||||
|
||||
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
||||
|
||||
const vercelTeamSlug = await VercelIntegrationRepository.getVercelClient(orgIntegration)
|
||||
.andThen((client) => VercelIntegrationRepository.getTeamSlug(client, teamId))
|
||||
.match(
|
||||
(slug) => slug,
|
||||
() => undefined
|
||||
);
|
||||
|
||||
// Use a serializable transaction to prevent duplicate project integrations
|
||||
// from concurrent selectVercelProject calls (read-then-write race condition).
|
||||
const txResult = await $transaction(
|
||||
this.#prismaClient,
|
||||
"selectVercelProject",
|
||||
async (tx) => {
|
||||
const existing = await tx.organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId: params.projectId,
|
||||
deletedAt: null,
|
||||
organizationIntegration: {
|
||||
service: "VERCEL",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organizationIntegration: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const parsedData = VercelProjectIntegrationDataSchema.safeParse(
|
||||
existing.integrationData
|
||||
);
|
||||
|
||||
const updated = await tx.organizationProjectIntegration.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
externalEntityId: params.vercelProjectId,
|
||||
integrationData: {
|
||||
...(parsedData.success ? parsedData.data : {}),
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
vercelProjectName: params.vercelProjectName,
|
||||
vercelTeamId: teamId,
|
||||
vercelTeamSlug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
integration: updated,
|
||||
wasCreated: false,
|
||||
vercelStagingEnvironment: parsedData.success
|
||||
? parsedData.data.config.vercelStagingEnvironment
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const integrationData = createDefaultVercelIntegrationData(
|
||||
params.vercelProjectId,
|
||||
params.vercelProjectName,
|
||||
teamId,
|
||||
vercelTeamSlug
|
||||
);
|
||||
|
||||
const created = await tx.organizationProjectIntegration.create({
|
||||
data: {
|
||||
organizationIntegrationId: orgIntegration.id,
|
||||
projectId: params.projectId,
|
||||
externalEntityId: params.vercelProjectId,
|
||||
integrationData: integrationData,
|
||||
installedBy: params.userId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
integration: created,
|
||||
wasCreated: true,
|
||||
vercelStagingEnvironment: null,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
|
||||
if (!txResult) {
|
||||
throw new Error("Failed to select Vercel project: transaction returned undefined");
|
||||
}
|
||||
|
||||
const { integration, wasCreated, vercelStagingEnvironment } = txResult;
|
||||
|
||||
const syncResultAsync = await VercelIntegrationRepository.syncApiKeysToVercel({
|
||||
projectId: params.projectId,
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
teamId,
|
||||
vercelStagingEnvironment,
|
||||
orgIntegration,
|
||||
});
|
||||
const syncResult = syncResultAsync.isOk()
|
||||
? { success: syncResultAsync.value.errors.length === 0, errors: syncResultAsync.value.errors }
|
||||
: { success: false, errors: [syncResultAsync.error.message] };
|
||||
|
||||
if (wasCreated) {
|
||||
const disableResult = await VercelIntegrationRepository.getVercelClient(orgIntegration)
|
||||
.andThen((client) =>
|
||||
VercelIntegrationRepository.disableAutoAssignCustomDomains(
|
||||
client,
|
||||
params.vercelProjectId,
|
||||
teamId
|
||||
)
|
||||
);
|
||||
|
||||
if (disableResult.isErr()) {
|
||||
logger.warn("Failed to disable autoAssignCustomDomains during project selection", {
|
||||
projectId: params.projectId,
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
error: disableResult.error.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("Vercel project selected and API keys synced", {
|
||||
projectId: params.projectId,
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
vercelProjectName: params.vercelProjectName,
|
||||
syncSuccess: syncResult.success,
|
||||
syncErrors: syncResult.errors,
|
||||
});
|
||||
}
|
||||
|
||||
return { integration, syncResult };
|
||||
}
|
||||
|
||||
async updateVercelIntegrationConfig(
|
||||
projectId: string,
|
||||
configUpdates: Partial<VercelIntegrationConfig>
|
||||
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
||||
const existing = await this.getVercelProjectIntegration(projectId);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedConfig = {
|
||||
...existing.parsedIntegrationData.config,
|
||||
...configUpdates,
|
||||
};
|
||||
|
||||
const updatedData: VercelProjectIntegrationData = {
|
||||
...existing.parsedIntegrationData,
|
||||
config: updatedConfig,
|
||||
};
|
||||
|
||||
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
integrationData: updatedData,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedConfig.atomicBuilds?.includes("prod")) {
|
||||
return { ...updated, parsedIntegrationData: updatedData };
|
||||
}
|
||||
|
||||
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(
|
||||
projectId
|
||||
);
|
||||
|
||||
if (orgIntegration) {
|
||||
await this.#syncTriggerVersionToVercelProduction(
|
||||
projectId,
|
||||
updatedConfig.atomicBuilds,
|
||||
orgIntegration
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...updated,
|
||||
parsedIntegrationData: updatedData,
|
||||
};
|
||||
}
|
||||
|
||||
async updateSyncEnvVarsMapping(
|
||||
projectId: string,
|
||||
syncEnvVarsMapping: SyncEnvVarsMapping
|
||||
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
||||
const existing = await this.getVercelProjectIntegration(projectId);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedData: VercelProjectIntegrationData = {
|
||||
...existing.parsedIntegrationData,
|
||||
syncEnvVarsMapping,
|
||||
};
|
||||
|
||||
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
integrationData: updatedData,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...updated,
|
||||
parsedIntegrationData: updatedData,
|
||||
};
|
||||
}
|
||||
|
||||
async updateSyncEnvVarForEnvironment(
|
||||
projectId: string,
|
||||
envVarKey: string,
|
||||
environmentType: TriggerEnvironmentType,
|
||||
syncEnabled: boolean
|
||||
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
||||
const existing = await this.getVercelProjectIntegration(projectId);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {};
|
||||
const envSlug = envTypeToSlug(environmentType);
|
||||
|
||||
const currentEnvSettings = currentMapping[envSlug] || {};
|
||||
|
||||
const updatedMapping: SyncEnvVarsMapping = {
|
||||
...currentMapping,
|
||||
[envSlug]: {
|
||||
...currentEnvSettings,
|
||||
[envVarKey]: syncEnabled,
|
||||
},
|
||||
};
|
||||
|
||||
const updatedData: VercelProjectIntegrationData = {
|
||||
...existing.parsedIntegrationData,
|
||||
syncEnvVarsMapping: updatedMapping,
|
||||
};
|
||||
|
||||
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
integrationData: updatedData,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...updated,
|
||||
parsedIntegrationData: updatedData,
|
||||
};
|
||||
}
|
||||
|
||||
async removeSyncEnvVarForEnvironment(
|
||||
projectId: string,
|
||||
envVarKey: string,
|
||||
environmentType: TriggerEnvironmentType
|
||||
): Promise<void> {
|
||||
const existing = await this.getVercelProjectIntegration(projectId);
|
||||
if (!existing) return;
|
||||
|
||||
const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {};
|
||||
const envSlug = envTypeToSlug(environmentType);
|
||||
const currentEnvSettings = currentMapping[envSlug];
|
||||
if (!currentEnvSettings || !(envVarKey in currentEnvSettings)) return;
|
||||
|
||||
const { [envVarKey]: _, ...rest } = currentEnvSettings;
|
||||
const updatedMapping = { ...currentMapping, [envSlug]: rest };
|
||||
|
||||
await this.#prismaClient.organizationProjectIntegration.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
integrationData: {
|
||||
...existing.parsedIntegrationData,
|
||||
syncEnvVarsMapping: updatedMapping,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async completeOnboarding(
|
||||
projectId: string,
|
||||
params: {
|
||||
vercelStagingEnvironment?: { environmentId: string; displayName: string } | null;
|
||||
pullEnvVarsBeforeBuild?: EnvSlug[] | null;
|
||||
atomicBuilds?: EnvSlug[] | null;
|
||||
discoverEnvVars?: EnvSlug[] | null;
|
||||
syncEnvVarsMapping?: SyncEnvVarsMapping;
|
||||
}
|
||||
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
||||
const existing = await this.getVercelProjectIntegration(projectId);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const syncEnvVarsMapping = params.syncEnvVarsMapping ?? { "dev":{}, "stg":{}, "prod":{}, "preview":{} };
|
||||
const updatedData: VercelProjectIntegrationData = {
|
||||
...existing.parsedIntegrationData,
|
||||
config: {
|
||||
...existing.parsedIntegrationData.config,
|
||||
pullEnvVarsBeforeBuild: params.pullEnvVarsBeforeBuild ?? null,
|
||||
atomicBuilds: params.atomicBuilds ?? null,
|
||||
discoverEnvVars: params.discoverEnvVars ?? null,
|
||||
vercelStagingEnvironment: params.vercelStagingEnvironment ?? null,
|
||||
},
|
||||
//This is intentionally not updated here, in case of resetting the onboarding it should not override the existing mapping with an empty one
|
||||
syncEnvVarsMapping: existing.parsedIntegrationData.syncEnvVarsMapping,
|
||||
onboardingCompleted: true,
|
||||
};
|
||||
|
||||
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
integrationData: updatedData,
|
||||
},
|
||||
});
|
||||
|
||||
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(
|
||||
projectId
|
||||
);
|
||||
|
||||
if (orgIntegration) {
|
||||
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
||||
|
||||
const pullResult = await VercelIntegrationRepository.pullEnvVarsFromVercel({
|
||||
projectId,
|
||||
vercelProjectId: updatedData.vercelProjectId,
|
||||
teamId,
|
||||
vercelStagingEnvironment: params.vercelStagingEnvironment,
|
||||
syncEnvVarsMapping,
|
||||
orgIntegration,
|
||||
});
|
||||
|
||||
if (pullResult.isErr()) {
|
||||
logger.error("Failed to pull env vars from Vercel during onboarding", {
|
||||
projectId,
|
||||
error: pullResult.error.message,
|
||||
});
|
||||
} else if (pullResult.value.errors.length > 0) {
|
||||
logger.warn("Errors pulling env vars from Vercel during onboarding", {
|
||||
projectId,
|
||||
errors: pullResult.value.errors,
|
||||
});
|
||||
}
|
||||
|
||||
await this.#syncTriggerVersionToVercelProduction(
|
||||
projectId,
|
||||
updatedData.config.atomicBuilds,
|
||||
orgIntegration
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...updated,
|
||||
parsedIntegrationData: updatedData,
|
||||
};
|
||||
}
|
||||
|
||||
async #syncTriggerVersionToVercelProduction(
|
||||
projectId: string,
|
||||
atomicBuilds: string[] | null | undefined,
|
||||
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }
|
||||
): Promise<void> {
|
||||
if (!atomicBuilds?.includes("prod")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prodEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
type: "PRODUCTION",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!prodEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDeployment = await findCurrentWorkerDeployment({
|
||||
environmentId: prodEnvironment.id,
|
||||
});
|
||||
|
||||
if (!currentDeployment?.version) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
|
||||
if (clientResult.isErr()) {
|
||||
logger.error("Failed to get Vercel client for TRIGGER_VERSION sync", {
|
||||
projectId,
|
||||
error: clientResult.error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = clientResult.value;
|
||||
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
||||
|
||||
// Get the Vercel project ID from the project integration
|
||||
const projectIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
organizationIntegrationId: orgIntegration.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
externalEntityId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectIntegration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vercelProjectId = projectIntegration.externalEntityId;
|
||||
|
||||
// Check if TRIGGER_VERSION already exists targeting production
|
||||
const envVarsResult = await VercelIntegrationRepository.getVercelEnvironmentVariables(
|
||||
client,
|
||||
vercelProjectId,
|
||||
teamId
|
||||
);
|
||||
|
||||
if (envVarsResult.isErr()) {
|
||||
logger.warn("Failed to fetch Vercel env vars for TRIGGER_VERSION sync", {
|
||||
projectId,
|
||||
vercelProjectId,
|
||||
error: envVarsResult.error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTriggerVersion = envVarsResult.value.find(
|
||||
(env) => env.key === "TRIGGER_VERSION" && env.target.includes("production")
|
||||
);
|
||||
|
||||
if (existingTriggerVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Push TRIGGER_VERSION to Vercel production
|
||||
const createResult = await ResultAsync.fromPromise(
|
||||
client.projects.createProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
upsert: "true",
|
||||
requestBody: {
|
||||
key: "TRIGGER_VERSION",
|
||||
value: currentDeployment.version,
|
||||
target: ["production"] as any,
|
||||
type: "encrypted",
|
||||
},
|
||||
}),
|
||||
(error) => error
|
||||
);
|
||||
|
||||
if (createResult.isErr()) {
|
||||
logger.error("Failed to sync TRIGGER_VERSION to Vercel production", {
|
||||
projectId,
|
||||
vercelProjectId,
|
||||
error: createResult.error instanceof Error ? createResult.error.message : String(createResult.error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Synced TRIGGER_VERSION to Vercel production", {
|
||||
projectId,
|
||||
vercelProjectId,
|
||||
version: currentDeployment.version,
|
||||
});
|
||||
}
|
||||
|
||||
async disconnectVercelProject(projectId: string): Promise<boolean> {
|
||||
const existing = await this.getVercelProjectIntegration(projectId);
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.#prismaClient.organizationProjectIntegration.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,14 @@ export function organizationSettingsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings`;
|
||||
}
|
||||
|
||||
export function organizationIntegrationsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/integrations`;
|
||||
}
|
||||
|
||||
export function organizationVercelIntegrationPath(organization: OrgForPath) {
|
||||
return `${organizationIntegrationsPath(organization)}/vercel`;
|
||||
}
|
||||
|
||||
function organizationParam(organization: OrgForPath) {
|
||||
return organization.slug;
|
||||
}
|
||||
@@ -151,6 +159,22 @@ export function githubAppInstallPath(organizationSlug: string, redirectTo: strin
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function vercelAppInstallPath(organizationSlug: string, projectSlug: string) {
|
||||
return `/vercel/install?org_slug=${organizationSlug}&project_slug=${projectSlug}`;
|
||||
}
|
||||
|
||||
export function vercelCallbackPath() {
|
||||
return `/vercel/callback`;
|
||||
}
|
||||
|
||||
export function vercelResourcePath(
|
||||
organizationSlug: string,
|
||||
projectSlug: string,
|
||||
environmentSlug: string
|
||||
) {
|
||||
return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/vercel`;
|
||||
}
|
||||
|
||||
export function v3EnvironmentPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
|
||||
@@ -6,9 +6,12 @@ import { env } from "~/env.server";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import {
|
||||
type CreateEnvironmentVariables,
|
||||
type CreateResult,
|
||||
type DeleteEnvironmentVariable,
|
||||
type DeleteEnvironmentVariableValue,
|
||||
type EditEnvironmentVariable,
|
||||
type EditEnvironmentVariableValue,
|
||||
type EnvironmentVariable,
|
||||
type EnvironmentVariableWithSecret,
|
||||
type ProjectEnvironmentVariable,
|
||||
@@ -45,18 +48,7 @@ const SecretValue = z.object({ secret: z.string() });
|
||||
export class EnvironmentVariablesRepository implements Repository {
|
||||
constructor(private prismaClient: PrismaClient = prisma) {}
|
||||
|
||||
async create(
|
||||
projectId: string,
|
||||
options: {
|
||||
override: boolean;
|
||||
environmentIds: string[];
|
||||
isSecret?: boolean;
|
||||
variables: {
|
||||
key: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
): Promise<CreateResult> {
|
||||
async create(projectId: string, options: CreateEnvironmentVariables): Promise<CreateResult> {
|
||||
const project = await this.prismaClient.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
@@ -164,10 +156,49 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
prismaClient: tx,
|
||||
});
|
||||
|
||||
// If parentEnvironmentId is provided and isSecret is not explicitly set,
|
||||
// look up if the parent has this variable marked as secret
|
||||
let inheritedIsSecret: boolean | undefined = undefined;
|
||||
if (options.isSecret === undefined && options.parentEnvironmentId) {
|
||||
const parentVariableValue = await tx.environmentVariableValue.findFirst({
|
||||
where: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId: options.parentEnvironmentId,
|
||||
},
|
||||
select: {
|
||||
isSecret: true,
|
||||
},
|
||||
});
|
||||
if (parentVariableValue?.isSecret) {
|
||||
inheritedIsSecret = true;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveIsSecret = options.isSecret ?? inheritedIsSecret;
|
||||
|
||||
//set the secret values and references
|
||||
for (const environmentId of options.environmentIds) {
|
||||
const key = secretKey(projectId, environmentId, variable.key);
|
||||
|
||||
const existingValueRecord = await tx.environmentVariableValue.findFirst({
|
||||
where: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if value already exists and is the same, and no metadata change (e.g. isSecret toggle)
|
||||
const existingSecret = await secretStore.getSecret(SecretValue, key);
|
||||
const canSkip =
|
||||
existingSecret &&
|
||||
existingSecret.secret === variable.value &&
|
||||
existingValueRecord &&
|
||||
(options.isSecret === undefined ||
|
||||
existingValueRecord.isSecret === options.isSecret);
|
||||
if (canSkip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//create the secret reference
|
||||
const secretReference = await tx.secretReference.upsert({
|
||||
where: {
|
||||
@@ -180,23 +211,36 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
update: {},
|
||||
});
|
||||
|
||||
const variableValue = await tx.environmentVariableValue.upsert({
|
||||
where: {
|
||||
variableId_environmentId: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId,
|
||||
if (existingValueRecord) {
|
||||
await tx.environmentVariableValue.update({
|
||||
where: {
|
||||
id: existingValueRecord.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId: environmentId,
|
||||
valueReferenceId: secretReference.id,
|
||||
isSecret: options.isSecret,
|
||||
},
|
||||
update: {
|
||||
isSecret: options.isSecret,
|
||||
},
|
||||
});
|
||||
data: {
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
...(options.lastUpdatedBy ? { lastUpdatedBy: options.lastUpdatedBy } : {}),
|
||||
valueReferenceId: secretReference.id,
|
||||
...(options.isSecret !== undefined
|
||||
? {
|
||||
isSecret: options.isSecret,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.environmentVariableValue.create({
|
||||
data: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId: environmentId,
|
||||
valueReferenceId: secretReference.id,
|
||||
isSecret: effectiveIsSecret,
|
||||
version: 1,
|
||||
lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await secretStore.setSecret<{ secret: string }>(key, {
|
||||
secret: variable.value,
|
||||
@@ -226,14 +270,7 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
}
|
||||
}
|
||||
|
||||
async edit(
|
||||
projectId: string,
|
||||
options: {
|
||||
values: { value: string; environmentId: string }[];
|
||||
id: string;
|
||||
keepEmptyValues?: boolean;
|
||||
}
|
||||
): Promise<Result> {
|
||||
async edit(projectId: string, options: EditEnvironmentVariable): Promise<Result> {
|
||||
const project = await this.prismaClient.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
@@ -323,6 +360,20 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
await secretStore.setSecret<{ secret: string }>(key, {
|
||||
secret: value.value,
|
||||
});
|
||||
await tx.environmentVariableValue.update({
|
||||
where: {
|
||||
variableId_environmentId: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId: value.environmentId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -340,6 +391,8 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId: value.environmentId,
|
||||
valueReferenceId: secretReference.id,
|
||||
version: 1,
|
||||
lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -360,14 +413,7 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
}
|
||||
}
|
||||
|
||||
async editValue(
|
||||
projectId: string,
|
||||
options: {
|
||||
id: string;
|
||||
environmentId: string;
|
||||
value: string;
|
||||
}
|
||||
): Promise<Result> {
|
||||
async editValue(projectId: string, options: EditEnvironmentVariableValue): Promise<Result> {
|
||||
const project = await this.prismaClient.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
@@ -426,6 +472,21 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
await secretStore.setSecret<{ secret: string }>(key, {
|
||||
secret: options.value,
|
||||
});
|
||||
|
||||
await tx.environmentVariableValue.update({
|
||||
where: {
|
||||
variableId_environmentId: {
|
||||
variableId: environmentVariable.id,
|
||||
environmentId: options.environmentId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,9 +6,25 @@ export const EnvironmentVariableKey = z
|
||||
.nonempty("Key is required")
|
||||
.regex(/^\w+$/, "Keys can only use alphanumeric characters and underscores");
|
||||
|
||||
export const EnvironmentVariableUpdaterSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("user"),
|
||||
userId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("integration"),
|
||||
integration: z.string(),
|
||||
}),
|
||||
]);
|
||||
export type EnvironmentVariableUpdater = z.infer<typeof EnvironmentVariableUpdaterSchema>;
|
||||
|
||||
export const CreateEnvironmentVariables = z.object({
|
||||
override: z.boolean(),
|
||||
environmentIds: z.array(z.string()),
|
||||
isSecret: z.boolean().optional(),
|
||||
parentEnvironmentId: z.string().optional(),
|
||||
variables: z.array(z.object({ key: EnvironmentVariableKey, value: z.string() })),
|
||||
lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(),
|
||||
});
|
||||
|
||||
export type CreateEnvironmentVariables = z.infer<typeof CreateEnvironmentVariables>;
|
||||
@@ -32,6 +48,7 @@ export const EditEnvironmentVariable = z.object({
|
||||
})
|
||||
),
|
||||
keepEmptyValues: z.boolean().optional(),
|
||||
lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(),
|
||||
});
|
||||
export type EditEnvironmentVariable = z.infer<typeof EditEnvironmentVariable>;
|
||||
|
||||
@@ -51,6 +68,7 @@ export const EditEnvironmentVariableValue = z.object({
|
||||
id: z.string(),
|
||||
environmentId: z.string(),
|
||||
value: z.string(),
|
||||
lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(),
|
||||
});
|
||||
export type EditEnvironmentVariableValue = z.infer<typeof EditEnvironmentVariableValue>;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { environmentTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { type Prisma, type prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
isIntegrationForService,
|
||||
type OrganizationIntegrationForService,
|
||||
OrgIntegrationRepository,
|
||||
} from "~/models/orgIntegration.server";
|
||||
@@ -644,7 +645,7 @@ export class DeliverAlertService extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
if (!integration || !isIntegrationForService(integration, "SLACK")) {
|
||||
logger.error("[DeliverAlert] Slack integration not found", {
|
||||
alert,
|
||||
});
|
||||
|
||||
@@ -221,6 +221,7 @@ export class InitializeDeploymentService extends BaseService {
|
||||
imageReference: imageRef,
|
||||
imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
|
||||
git: payload.gitMeta ?? undefined,
|
||||
commitSHA: payload.gitMeta?.commitSha ?? undefined,
|
||||
runtime: payload.runtime ?? undefined,
|
||||
triggeredVia: payload.triggeredVia ?? undefined,
|
||||
startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export * from "./vercelProjectIntegrationSchema";
|
||||
|
||||
export function getVercelInstallParams(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const configurationId = url.searchParams.get("configurationId");
|
||||
const integration = url.searchParams.get("integration");
|
||||
const next = url.searchParams.get("next");
|
||||
|
||||
if (code && configurationId && (integration === "vercel" || !integration)) {
|
||||
return { code, configurationId, next };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export const VercelOAuthStateSchema = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentSlug: z.string(),
|
||||
organizationSlug: z.string(),
|
||||
projectSlug: z.string(),
|
||||
});
|
||||
|
||||
export type VercelOAuthState = z.infer<typeof VercelOAuthStateSchema>;
|
||||
|
||||
export async function generateVercelOAuthState(
|
||||
params: VercelOAuthState
|
||||
): Promise<string> {
|
||||
return generateJWT({
|
||||
secretKey: env.ENCRYPTION_KEY,
|
||||
payload: params,
|
||||
expirationTime: "15m",
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateVercelOAuthState(
|
||||
token: string
|
||||
): Promise<{ ok: true; state: VercelOAuthState } | { ok: false; error: string }> {
|
||||
const result = await validateJWT(token, env.ENCRYPTION_KEY);
|
||||
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.error };
|
||||
}
|
||||
|
||||
const parseResult = VercelOAuthStateSchema.safeParse(result.payload);
|
||||
if (!parseResult.success) {
|
||||
return { ok: false, error: "Invalid state payload" };
|
||||
}
|
||||
|
||||
return { ok: true, state: parseResult.data };
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Result } from "neverthrow";
|
||||
import { z } from "zod";
|
||||
|
||||
export const EnvSlugSchema = z.enum(["dev", "stg", "prod", "preview"]);
|
||||
export type EnvSlug = z.infer<typeof EnvSlugSchema>;
|
||||
|
||||
export const ALL_ENV_SLUGS: EnvSlug[] = ["dev", "stg", "prod", "preview"];
|
||||
|
||||
const safeJsonParse = Result.fromThrowable(
|
||||
(val: string) => JSON.parse(val) as unknown,
|
||||
() => null
|
||||
);
|
||||
|
||||
/**
|
||||
* Zod transform for form fields that submit JSON-encoded arrays.
|
||||
* Parses the string as JSON and returns the array, or null if invalid.
|
||||
*/
|
||||
export const jsonArrayField = z.string().optional().transform((val) => {
|
||||
if (!val) return null;
|
||||
return safeJsonParse(val).match(
|
||||
(parsed) => (Array.isArray(parsed) ? parsed : null),
|
||||
() => null
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod transform for form fields that submit JSON-encoded EnvSlug arrays.
|
||||
* Parses the string as JSON and validates each element is a valid EnvSlug.
|
||||
* Invalid elements are filtered out rather than rejecting the whole array.
|
||||
*/
|
||||
export const envSlugArrayField = z.string().optional().transform((val): EnvSlug[] | null => {
|
||||
if (!val) return null;
|
||||
return safeJsonParse(val).match(
|
||||
(parsed) => {
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter((item): item is EnvSlug => EnvSlugSchema.safeParse(item).success);
|
||||
},
|
||||
() => null
|
||||
);
|
||||
});
|
||||
|
||||
export const VercelIntegrationConfigSchema = z.object({
|
||||
atomicBuilds: z.array(EnvSlugSchema).nullable().optional(),
|
||||
pullEnvVarsBeforeBuild: z.array(EnvSlugSchema).nullable().optional(),
|
||||
/** Maps a custom Vercel environment to Trigger.dev's staging environment. */
|
||||
vercelStagingEnvironment: z.object({
|
||||
environmentId: z.string(),
|
||||
displayName: z.string(),
|
||||
}).nullable().optional(),
|
||||
discoverEnvVars: z.array(EnvSlugSchema).nullable().optional(),
|
||||
});
|
||||
|
||||
export type VercelIntegrationConfig = z.infer<typeof VercelIntegrationConfigSchema>;
|
||||
|
||||
export const TriggerEnvironmentType = z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]);
|
||||
export type TriggerEnvironmentType = z.infer<typeof TriggerEnvironmentType>;
|
||||
|
||||
/**
|
||||
* Per-environment, per-variable sync settings.
|
||||
* Missing env slug = sync all vars. Missing var in env = sync by default.
|
||||
* Only explicitly `false` entries disable sync.
|
||||
*/
|
||||
export const SyncEnvVarsMappingSchema = z.record(EnvSlugSchema, z.record(z.string(), z.boolean())).default({});
|
||||
|
||||
export type SyncEnvVarsMapping = z.infer<typeof SyncEnvVarsMappingSchema>;
|
||||
|
||||
export const VercelProjectIntegrationDataSchema = z.object({
|
||||
config: VercelIntegrationConfigSchema,
|
||||
syncEnvVarsMapping: SyncEnvVarsMappingSchema,
|
||||
vercelProjectName: z.string(),
|
||||
vercelTeamId: z.string().nullable(),
|
||||
vercelTeamSlug: z.string().optional(),
|
||||
vercelProjectId: z.string(),
|
||||
onboardingCompleted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type VercelProjectIntegrationData = z.infer<typeof VercelProjectIntegrationDataSchema>;
|
||||
|
||||
export function createDefaultVercelIntegrationData(
|
||||
vercelProjectId: string,
|
||||
vercelProjectName: string,
|
||||
vercelTeamId: string | null,
|
||||
vercelTeamSlug?: string
|
||||
): VercelProjectIntegrationData {
|
||||
return {
|
||||
config: {
|
||||
atomicBuilds: ["prod"],
|
||||
pullEnvVarsBeforeBuild: ["prod", "stg", "preview"],
|
||||
discoverEnvVars: ["prod", "stg", "preview"],
|
||||
vercelStagingEnvironment: null,
|
||||
},
|
||||
syncEnvVarsMapping: {},
|
||||
vercelProjectId,
|
||||
vercelProjectName,
|
||||
vercelTeamId,
|
||||
vercelTeamSlug,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Trigger.dev environment type to its Vercel target identifier(s).
|
||||
* Returns null for STAGING when no custom environment is configured.
|
||||
*/
|
||||
export function envTypeToVercelTarget(
|
||||
envType: TriggerEnvironmentType,
|
||||
stagingEnvironmentId?: string | null
|
||||
): string[] | null {
|
||||
switch (envType) {
|
||||
case "PRODUCTION":
|
||||
return ["production"];
|
||||
case "STAGING":
|
||||
return stagingEnvironmentId ? [stagingEnvironmentId] : null;
|
||||
case "PREVIEW":
|
||||
return ["preview"];
|
||||
case "DEVELOPMENT":
|
||||
return ["development"];
|
||||
}
|
||||
}
|
||||
|
||||
export function getAvailableEnvSlugs(
|
||||
hasStagingEnvironment: boolean,
|
||||
hasPreviewEnvironment: boolean
|
||||
): EnvSlug[] {
|
||||
return ALL_ENV_SLUGS.filter((s) => {
|
||||
if (s === "stg" && !hasStagingEnvironment) return false;
|
||||
if (s === "preview" && !hasPreviewEnvironment) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function getAvailableEnvSlugsForBuildSettings(
|
||||
hasStagingEnvironment: boolean,
|
||||
hasPreviewEnvironment: boolean
|
||||
): EnvSlug[] {
|
||||
return getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment).filter((s) => s !== "dev");
|
||||
}
|
||||
|
||||
export function isDiscoverEnvVarsEnabledForEnvironment(
|
||||
discoverEnvVars: EnvSlug[] | null | undefined,
|
||||
environmentType: TriggerEnvironmentType
|
||||
): boolean {
|
||||
if (!discoverEnvVars || discoverEnvVars.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const envSlug = envTypeToSlug(environmentType);
|
||||
return discoverEnvVars.includes(envSlug);
|
||||
}
|
||||
|
||||
export function envTypeToSlug(environmentType: TriggerEnvironmentType): EnvSlug {
|
||||
switch (environmentType) {
|
||||
case "DEVELOPMENT":
|
||||
return "dev";
|
||||
case "STAGING":
|
||||
return "stg";
|
||||
case "PRODUCTION":
|
||||
return "prod";
|
||||
case "PREVIEW":
|
||||
return "preview";
|
||||
}
|
||||
}
|
||||
|
||||
export function envSlugToType(slug: EnvSlug): TriggerEnvironmentType {
|
||||
switch (slug) {
|
||||
case "dev":
|
||||
return "DEVELOPMENT";
|
||||
case "stg":
|
||||
return "STAGING";
|
||||
case "prod":
|
||||
return "PRODUCTION";
|
||||
case "preview":
|
||||
return "PREVIEW";
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSyncEnvVar(
|
||||
mapping: SyncEnvVarsMapping,
|
||||
envVarName: string,
|
||||
environmentType: TriggerEnvironmentType
|
||||
): boolean {
|
||||
const envSlug = envTypeToSlug(environmentType);
|
||||
const envSettings = mapping[envSlug];
|
||||
if (!envSettings) {
|
||||
return true;
|
||||
}
|
||||
return envSettings[envVarName] !== false;
|
||||
}
|
||||
|
||||
export function shouldSyncEnvVarForAnyEnvironment(
|
||||
mapping: SyncEnvVarsMapping,
|
||||
envVarName: string
|
||||
): boolean {
|
||||
for (const slug of ALL_ENV_SLUGS) {
|
||||
const envSettings = mapping[slug];
|
||||
if (!envSettings) {
|
||||
return true;
|
||||
}
|
||||
if (envSettings[envVarName] !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPullEnvVarsEnabledForEnvironment(
|
||||
pullEnvVarsBeforeBuild: EnvSlug[] | null | undefined,
|
||||
environmentType: TriggerEnvironmentType
|
||||
): boolean {
|
||||
if (!pullEnvVarsBeforeBuild || pullEnvVarsBeforeBuild.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const envSlug = envTypeToSlug(environmentType);
|
||||
return pullEnvVarsBeforeBuild.includes(envSlug);
|
||||
}
|
||||
|
||||
export function isAtomicBuildsEnabledForEnvironment(
|
||||
atomicBuilds: EnvSlug[] | null | undefined,
|
||||
environmentType: TriggerEnvironmentType
|
||||
): boolean {
|
||||
if (!atomicBuilds || atomicBuilds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const envSlug = envTypeToSlug(environmentType);
|
||||
return atomicBuilds.includes(envSlug);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Validates `next` parameter from Vercel callbacks.
|
||||
* Only allows vercel.com subdomains (the expected source) and same-origin relative paths.
|
||||
*/
|
||||
export function sanitizeVercelNextUrl(url: string | undefined | null): string | undefined {
|
||||
if (!url) return undefined;
|
||||
|
||||
// Allow relative paths (same-origin) but reject protocol-relative URLs
|
||||
if (url.startsWith("/") && !url.startsWith("//")) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (
|
||||
parsed.protocol === "https:" &&
|
||||
/^([a-z0-9-]+\.)*vercel\.com$/i.test(parsed.hostname)
|
||||
) {
|
||||
return parsed.toString();
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -129,6 +129,7 @@
|
||||
"@unkey/cache": "^1.5.0",
|
||||
"@unkey/error": "^0.2.0",
|
||||
"@upstash/ratelimit": "^1.1.3",
|
||||
"@vercel/sdk": "^1.19.1",
|
||||
"@whatwg-node/fetch": "^0.9.14",
|
||||
"ai": "^4.3.19",
|
||||
"assert-never": "^1.2.1",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeVercelNextUrl } from "../app/v3/vercel/vercelUrls.server";
|
||||
|
||||
describe("sanitizeVercelNextUrl", () => {
|
||||
it("returns undefined for null/undefined/empty", () => {
|
||||
expect(sanitizeVercelNextUrl(null)).toBeUndefined();
|
||||
expect(sanitizeVercelNextUrl(undefined)).toBeUndefined();
|
||||
expect(sanitizeVercelNextUrl("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows relative paths", () => {
|
||||
expect(sanitizeVercelNextUrl("/dashboard")).toBe("/dashboard");
|
||||
expect(sanitizeVercelNextUrl("/some/path?query=1")).toBe("/some/path?query=1");
|
||||
});
|
||||
|
||||
it("rejects protocol-relative URLs", () => {
|
||||
expect(sanitizeVercelNextUrl("//evil.com/path")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows vercel.com URLs", () => {
|
||||
expect(sanitizeVercelNextUrl("https://vercel.com/dashboard")).toBe(
|
||||
"https://vercel.com/dashboard"
|
||||
);
|
||||
expect(sanitizeVercelNextUrl("https://app.vercel.com/settings")).toBe(
|
||||
"https://app.vercel.com/settings"
|
||||
);
|
||||
});
|
||||
|
||||
it("allows vercel.com subdomains", () => {
|
||||
expect(sanitizeVercelNextUrl("https://my-team.vercel.com/project")).toBe(
|
||||
"https://my-team.vercel.com/project"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-vercel HTTPS URLs", () => {
|
||||
expect(sanitizeVercelNextUrl("https://evil.com/path")).toBeUndefined();
|
||||
expect(sanitizeVercelNextUrl("https://not-vercel.com")).toBeUndefined();
|
||||
expect(sanitizeVercelNextUrl("https://vercel.com.evil.com")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects HTTP vercel.com URLs", () => {
|
||||
expect(sanitizeVercelNextUrl("http://vercel.com/dashboard")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects javascript: URLs", () => {
|
||||
expect(sanitizeVercelNextUrl("javascript:alert(1)")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects data: URLs", () => {
|
||||
expect(sanitizeVercelNextUrl("data:text/html,<script>alert(1)</script>")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects invalid URLs", () => {
|
||||
expect(sanitizeVercelNextUrl("not a url at all")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."EnvironmentVariableValue" ADD COLUMN "lastUpdatedBy" JSONB,
|
||||
ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."OrganizationProjectIntegration" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationIntegrationId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"externalEntityId" TEXT NOT NULL,
|
||||
"integrationData" JSONB NOT NULL,
|
||||
"installedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "OrganizationProjectIntegration_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganizationProjectIntegration_projectId_idx" ON "public"."OrganizationProjectIntegration"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganizationProjectIntegration_projectId_organizationIntegr_idx" ON "public"."OrganizationProjectIntegration"("projectId", "organizationIntegrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganizationProjectIntegration_externalEntityId_idx" ON "public"."OrganizationProjectIntegration"("externalEntityId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."OrganizationProjectIntegration" ADD CONSTRAINT "OrganizationProjectIntegration_organizationIntegrationId_fkey" FOREIGN KEY ("organizationIntegrationId") REFERENCES "public"."OrganizationIntegration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."OrganizationProjectIntegration" ADD CONSTRAINT "OrganizationProjectIntegration_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."IntegrationDeployment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"integrationName" TEXT NOT NULL,
|
||||
"integrationDeploymentId" TEXT NOT NULL,
|
||||
"commitSHA" TEXT NOT NULL,
|
||||
"deploymentId" TEXT,
|
||||
"status" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "IntegrationDeployment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IntegrationDeployment_deploymentId_idx" ON "public"."IntegrationDeployment"("deploymentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IntegrationDeployment_commitSHA_idx" ON "public"."IntegrationDeployment"("commitSHA");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."IntegrationDeployment" ADD CONSTRAINT "IntegrationDeployment_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "public"."WorkerDeployment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."IntegrationService" ADD VALUE 'VERCEL';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."OrganizationIntegration" ADD COLUMN "deletedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "externalOrganizationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "commitSHA" TEXT;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "OrganizationIntegration_externalOrganizationId_idx" ON "public"."OrganizationIntegration"("externalOrganizationId");
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "WorkerDeployment_commitSHA_idx" ON "public"."WorkerDeployment"("commitSHA");
|
||||
@@ -384,28 +384,29 @@ model Project {
|
||||
/// The master queues they are allowed to use (impacts what they can set as default and trigger runs with)
|
||||
allowedWorkerQueues String[] @default([]) @map("allowedMasterQueues")
|
||||
|
||||
environments RuntimeEnvironment[]
|
||||
backgroundWorkers BackgroundWorker[]
|
||||
backgroundWorkerTasks BackgroundWorkerTask[]
|
||||
taskRuns TaskRun[]
|
||||
runTags TaskRunTag[]
|
||||
taskQueues TaskQueue[]
|
||||
environmentVariables EnvironmentVariable[]
|
||||
checkpoints Checkpoint[]
|
||||
WorkerDeployment WorkerDeployment[]
|
||||
CheckpointRestoreEvent CheckpointRestoreEvent[]
|
||||
taskSchedules TaskSchedule[]
|
||||
alertChannels ProjectAlertChannel[]
|
||||
alerts ProjectAlert[]
|
||||
alertStorages ProjectAlertStorage[]
|
||||
bulkActionGroups BulkActionGroup[]
|
||||
BackgroundWorkerFile BackgroundWorkerFile[]
|
||||
waitpoints Waitpoint[]
|
||||
taskRunWaitpoints TaskRunWaitpoint[]
|
||||
taskRunCheckpoints TaskRunCheckpoint[]
|
||||
waitpointTags WaitpointTag[]
|
||||
connectedGithubRepository ConnectedGithubRepository?
|
||||
customerQueries CustomerQuery[]
|
||||
environments RuntimeEnvironment[]
|
||||
backgroundWorkers BackgroundWorker[]
|
||||
backgroundWorkerTasks BackgroundWorkerTask[]
|
||||
taskRuns TaskRun[]
|
||||
runTags TaskRunTag[]
|
||||
taskQueues TaskQueue[]
|
||||
environmentVariables EnvironmentVariable[]
|
||||
checkpoints Checkpoint[]
|
||||
WorkerDeployment WorkerDeployment[]
|
||||
CheckpointRestoreEvent CheckpointRestoreEvent[]
|
||||
taskSchedules TaskSchedule[]
|
||||
alertChannels ProjectAlertChannel[]
|
||||
alerts ProjectAlert[]
|
||||
alertStorages ProjectAlertStorage[]
|
||||
bulkActionGroups BulkActionGroup[]
|
||||
BackgroundWorkerFile BackgroundWorkerFile[]
|
||||
waitpoints Waitpoint[]
|
||||
taskRunWaitpoints TaskRunWaitpoint[]
|
||||
taskRunCheckpoints TaskRunCheckpoint[]
|
||||
waitpointTags WaitpointTag[]
|
||||
connectedGithubRepository ConnectedGithubRepository?
|
||||
organizationProjectIntegration OrganizationProjectIntegration[]
|
||||
customerQueries CustomerQuery[]
|
||||
|
||||
buildSettings Json?
|
||||
taskScheduleInstances TaskScheduleInstance[]
|
||||
@@ -1712,6 +1713,9 @@ model EnvironmentVariableValue {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
version Int @default(1)
|
||||
lastUpdatedBy Json?
|
||||
|
||||
@@unique([variableId, environmentId])
|
||||
}
|
||||
|
||||
@@ -1825,9 +1829,10 @@ model WorkerDeployment {
|
||||
worker BackgroundWorker? @relation(fields: [workerId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
workerId String? @unique
|
||||
|
||||
triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
triggeredById String?
|
||||
triggeredVia String?
|
||||
triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
triggeredById String?
|
||||
triggeredVia String?
|
||||
commitSHA String?
|
||||
|
||||
startedAt DateTime?
|
||||
installedAt DateTime?
|
||||
@@ -1846,12 +1851,14 @@ model WorkerDeployment {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
promotions WorkerDeploymentPromotion[]
|
||||
alerts ProjectAlert[]
|
||||
workerInstance WorkerInstance[]
|
||||
promotions WorkerDeploymentPromotion[]
|
||||
alerts ProjectAlert[]
|
||||
workerInstance WorkerInstance[]
|
||||
integrationDeployments IntegrationDeployment[]
|
||||
|
||||
@@unique([projectId, shortCode])
|
||||
@@unique([environmentId, version])
|
||||
@@index([commitSHA])
|
||||
}
|
||||
|
||||
enum WorkerDeploymentStatus {
|
||||
@@ -2088,7 +2095,8 @@ model OrganizationIntegration {
|
||||
|
||||
friendlyId String @unique
|
||||
|
||||
service IntegrationService
|
||||
service IntegrationService
|
||||
externalOrganizationId String? /// Identifier for external, integration's organization (e.g. Vercel's team)
|
||||
|
||||
integrationData Json
|
||||
|
||||
@@ -2100,12 +2108,39 @@ model OrganizationIntegration {
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
alertChannels ProjectAlertChannel[]
|
||||
alertChannels ProjectAlertChannel[]
|
||||
organizationProjectIntegration OrganizationProjectIntegration[]
|
||||
|
||||
@@index([externalOrganizationId])
|
||||
}
|
||||
|
||||
model OrganizationProjectIntegration {
|
||||
id String @id @default(cuid())
|
||||
|
||||
organizationIntegration OrganizationIntegration @relation(fields: [organizationIntegrationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
organizationIntegrationId String
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
projectId String
|
||||
|
||||
externalEntityId String /// Identifier for webhooks, for example Vercel's projectId
|
||||
integrationData Json /// Save useful data like config or external entity name
|
||||
installedBy String? /// UserId who installed the integration
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
@@index([projectId])
|
||||
@@index([projectId, organizationIntegrationId])
|
||||
@@index([externalEntityId])
|
||||
}
|
||||
|
||||
enum IntegrationService {
|
||||
SLACK
|
||||
VERCEL
|
||||
}
|
||||
|
||||
/// Bulk actions, like canceling and replaying runs
|
||||
@@ -2486,3 +2521,21 @@ model CustomerQuery {
|
||||
/// For Stripe metering job - find unprocessed queries
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model IntegrationDeployment {
|
||||
id String @id @default(cuid())
|
||||
|
||||
integrationName String /// For example Vercel
|
||||
integrationDeploymentId String /// External ID
|
||||
commitSHA String
|
||||
deploymentId String?
|
||||
status String? /// External deployment status
|
||||
|
||||
workerDeployment WorkerDeployment? @relation(fields: [deploymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([commitSHA])
|
||||
@@index([deploymentId])
|
||||
}
|
||||
|
||||
@@ -694,6 +694,7 @@ export const GetDeploymentResponseBody = z.object({
|
||||
version: z.string(),
|
||||
imageReference: z.string().nullish(),
|
||||
imagePlatform: z.string(),
|
||||
commitSHA: z.string().nullish(),
|
||||
externalBuildData: ExternalBuildData.optional().nullable(),
|
||||
errorData: DeploymentErrorData.nullish(),
|
||||
worker: z
|
||||
@@ -710,6 +711,17 @@ export const GetDeploymentResponseBody = z.object({
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
integrationDeployments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
integrationName: z.string(),
|
||||
integrationDeploymentId: z.string(),
|
||||
commitSHA: z.string(),
|
||||
createdAt: z.coerce.date(),
|
||||
})
|
||||
)
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export type GetDeploymentResponseBody = z.infer<typeof GetDeploymentResponseBody>;
|
||||
@@ -1139,6 +1151,12 @@ export const ImportEnvironmentVariablesRequestBody = z.object({
|
||||
variables: z.record(z.string()),
|
||||
parentVariables: z.record(z.string()).optional(),
|
||||
override: z.boolean().optional(),
|
||||
source: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("user"), userId: z.string() }),
|
||||
z.object({ type: z.literal("integration"), integration: z.string() }),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ImportEnvironmentVariablesRequestBody = z.infer<
|
||||
|
||||
Generated
+292
-43
@@ -521,6 +521,9 @@ importers:
|
||||
'@upstash/ratelimit':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(patch_hash=e5922e50fbefb7b2b24950c4b1c5c9ddc4cd25464439c9548d2298c432debe74)
|
||||
'@vercel/sdk':
|
||||
specifier: ^1.19.1
|
||||
version: 1.19.1
|
||||
'@whatwg-node/fetch':
|
||||
specifier: ^0.9.14
|
||||
version: 0.9.14
|
||||
@@ -1417,7 +1420,7 @@ importers:
|
||||
version: 0.0.1-cli.2.80.0
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.25.2
|
||||
version: 1.25.2(hono@4.5.11)(supports-color@10.0.0)(zod@3.25.76)
|
||||
version: 1.25.2(hono@4.11.8)(supports-color@10.0.0)(zod@3.25.76)
|
||||
'@opentelemetry/api':
|
||||
specifier: 1.9.0
|
||||
version: 1.9.0
|
||||
@@ -1785,7 +1788,7 @@ importers:
|
||||
version: 4.0.14
|
||||
ai:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.39(zod@3.25.76)
|
||||
version: 6.0.3(zod@3.25.76)
|
||||
defu:
|
||||
specifier: ^6.1.4
|
||||
version: 6.1.4
|
||||
@@ -2070,7 +2073,7 @@ importers:
|
||||
version: 8.5.4
|
||||
ai:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.39(zod@3.25.76)
|
||||
version: 6.0.3(zod@3.25.76)
|
||||
encoding:
|
||||
specifier: ^0.1.13
|
||||
version: 0.1.13
|
||||
@@ -2436,7 +2439,7 @@ importers:
|
||||
version: link:../../packages/trigger-sdk
|
||||
'@uploadthing/react':
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))
|
||||
version: 7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))
|
||||
ai:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(react@18.3.1)(zod@3.25.76)
|
||||
@@ -2475,7 +2478,7 @@ importers:
|
||||
version: 1.0.7(tailwindcss@3.4.1)
|
||||
uploadthing:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1)
|
||||
version: 7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1)
|
||||
zod:
|
||||
specifier: 3.25.76
|
||||
version: 3.25.76
|
||||
@@ -2843,8 +2846,8 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/gateway@3.0.16':
|
||||
resolution: {integrity: sha512-OOY5CfRJiHvh/8np2vs1RQaCZ5hWv2qOeEmmeiABXK3gLQHUVnCO+1hhoLsZdHM5iElu6M407dAOfyvTsKJqcQ==}
|
||||
'@ai-sdk/gateway@3.0.2':
|
||||
resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
@@ -2921,8 +2924,8 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.8':
|
||||
resolution: {integrity: sha512-ns9gN7MmpI8vTRandzgz+KK/zNMLzhrriiKECMt4euLtQFSBgNfydtagPOX4j4pS1/3KvHF6RivhT3gNQgBZsg==}
|
||||
'@ai-sdk/provider-utils@4.0.1':
|
||||
resolution: {integrity: sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
@@ -2947,8 +2950,8 @@ packages:
|
||||
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/provider@3.0.4':
|
||||
resolution: {integrity: sha512-5KXyBOSEX+l67elrEa+wqo/LSsSTtrPj9Uoh3zMbe/ceQX4ucHI3b9nUEfNkGF3Ry1svv90widAt+aiKdIJasQ==}
|
||||
'@ai-sdk/provider@3.0.0':
|
||||
resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/react@1.0.0':
|
||||
@@ -5872,6 +5875,16 @@ packages:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@modelcontextprotocol/sdk@1.26.0':
|
||||
resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@msgpack/msgpack@3.0.0-beta2':
|
||||
resolution: {integrity: sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -11094,8 +11107,8 @@ packages:
|
||||
resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@vercel/oidc@3.1.0':
|
||||
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
|
||||
'@vercel/oidc@3.0.5':
|
||||
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@vercel/otel@1.13.0':
|
||||
@@ -11115,6 +11128,10 @@ packages:
|
||||
engines: {node: '>=18.14'}
|
||||
deprecated: '@vercel/postgres is deprecated. You can either choose an alternate storage solution from the Vercel Marketplace if you want to set up a new database. Or you can follow this guide to migrate your existing Vercel Postgres db: https://neon.com/docs/guides/vercel-postgres-transition-guide'
|
||||
|
||||
'@vercel/sdk@1.19.1':
|
||||
resolution: {integrity: sha512-K4rmtUT6t1vX06tiY44ot8A7W1FKN7g/tMkE7yZghCgNQ8b30SzljBd4ni8RNp2pJzM/HrZmphRDeIArO7oZuw==}
|
||||
hasBin: true
|
||||
|
||||
'@vitest/coverage-v8@3.1.4':
|
||||
resolution: {integrity: sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==}
|
||||
peerDependencies:
|
||||
@@ -11435,8 +11452,8 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ai@6.0.39:
|
||||
resolution: {integrity: sha512-hF05gF4H+IxuilA8kNANVVHQXduTJsJaH74jmlmy8mcQt3NZgPYe2zZNyGBV4DPDYTUDt1h31hbLgQqJTn5LGA==}
|
||||
ai@6.0.3:
|
||||
resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
@@ -11806,6 +11823,10 @@ packages:
|
||||
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
bottleneck@2.19.5:
|
||||
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
|
||||
|
||||
@@ -12764,6 +12785,15 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize-keys@1.1.1:
|
||||
resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -13720,6 +13750,12 @@ packages:
|
||||
peerDependencies:
|
||||
express: ^4.11 || 5 || ^5.0.0-beta.1
|
||||
|
||||
express-rate-limit@8.2.1:
|
||||
resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express@4.20.0:
|
||||
resolution: {integrity: sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
@@ -13728,6 +13764,10 @@ packages:
|
||||
resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
exsolve@1.0.7:
|
||||
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
|
||||
|
||||
@@ -14381,6 +14421,10 @@ packages:
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
hono@4.11.8:
|
||||
resolution: {integrity: sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hono@4.5.11:
|
||||
resolution: {integrity: sha512-62FcjLPtjAFwISVBUshryl+vbHOjg8rE4uIK/dxyR8GpLztunZpwFmfEvmJCUI7xoGh/Sr3CGCDPCmYxVw7wUQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -14419,6 +14463,10 @@ packages:
|
||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -14467,6 +14515,10 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
icss-utils@5.1.0:
|
||||
resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==}
|
||||
engines: {node: ^10 || ^12 || >= 14}
|
||||
@@ -14579,6 +14631,10 @@ packages:
|
||||
resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
ip-address@10.0.1:
|
||||
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ip-address@9.0.5:
|
||||
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -15841,6 +15897,10 @@ packages:
|
||||
resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -15849,6 +15909,10 @@ packages:
|
||||
resolution: {integrity: sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -17498,6 +17562,10 @@ packages:
|
||||
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
raw-body@3.0.2:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
rc9@2.1.2:
|
||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||
|
||||
@@ -18073,6 +18141,10 @@ packages:
|
||||
resolution: {integrity: sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
router@2.2.0:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
rtl-css-js@1.16.1:
|
||||
resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==}
|
||||
|
||||
@@ -18200,6 +18272,10 @@ packages:
|
||||
resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
serialize-javascript@6.0.1:
|
||||
resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==}
|
||||
|
||||
@@ -18214,6 +18290,10 @@ packages:
|
||||
resolution: {integrity: sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
serve-static@2.2.1:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
@@ -18513,6 +18593,10 @@ packages:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@3.7.0:
|
||||
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==}
|
||||
|
||||
@@ -18824,10 +18908,6 @@ packages:
|
||||
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tapable@2.2.2:
|
||||
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tapable@2.3.0:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -19311,6 +19391,10 @@ packages:
|
||||
resolution: {integrity: sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
type-is@2.0.1:
|
||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typed-array-buffer@1.0.2:
|
||||
resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -20079,6 +20163,11 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
|
||||
zod-to-json-schema@3.25.1:
|
||||
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
|
||||
zod-validation-error@1.5.0:
|
||||
resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -20132,11 +20221,11 @@ snapshots:
|
||||
'@vercel/oidc': 3.0.3
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/gateway@3.0.16(zod@3.25.76)':
|
||||
'@ai-sdk/gateway@3.0.2(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.4
|
||||
'@ai-sdk/provider-utils': 4.0.8(zod@3.25.76)
|
||||
'@vercel/oidc': 3.1.0
|
||||
'@ai-sdk/provider': 3.0.0
|
||||
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
|
||||
'@vercel/oidc': 3.0.5
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/openai@1.0.1(zod@3.25.76)':
|
||||
@@ -20216,9 +20305,9 @@ snapshots:
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.24.6(zod@3.25.76)
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.8(zod@3.25.76)':
|
||||
'@ai-sdk/provider-utils@4.0.1(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.4
|
||||
'@ai-sdk/provider': 3.0.0
|
||||
'@standard-schema/spec': 1.1.0
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 3.25.76
|
||||
@@ -20243,7 +20332,7 @@ snapshots:
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/provider@3.0.4':
|
||||
'@ai-sdk/provider@3.0.0':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
@@ -23756,9 +23845,9 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.5.11
|
||||
|
||||
'@hono/node-server@1.19.9(hono@4.5.11)':
|
||||
'@hono/node-server@1.19.9(hono@4.11.8)':
|
||||
dependencies:
|
||||
hono: 4.5.11
|
||||
hono: 4.11.8
|
||||
|
||||
'@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)':
|
||||
dependencies:
|
||||
@@ -24039,7 +24128,7 @@ snapshots:
|
||||
'@jridgewell/source-map@0.3.3':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.8
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
@@ -24220,9 +24309,9 @@ snapshots:
|
||||
|
||||
'@microsoft/fetch-event-source@2.0.1': {}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.25.2(hono@4.5.11)(supports-color@10.0.0)(zod@3.25.76)':
|
||||
'@modelcontextprotocol/sdk@1.25.2(hono@4.11.8)(supports-color@10.0.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9(hono@4.5.11)
|
||||
'@hono/node-server': 1.19.9(hono@4.11.8)
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
content-type: 1.0.5
|
||||
@@ -24242,6 +24331,28 @@ snapshots:
|
||||
- hono
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9(hono@4.11.8)
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.5
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.5
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.2.1(express@5.2.1)
|
||||
hono: 4.11.8
|
||||
jose: 6.1.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.0
|
||||
raw-body: 3.0.0
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@msgpack/msgpack@3.0.0-beta2': {}
|
||||
|
||||
'@neondatabase/serverless@0.9.5':
|
||||
@@ -31234,12 +31345,12 @@ snapshots:
|
||||
|
||||
'@uploadthing/mime-types@0.3.0': {}
|
||||
|
||||
'@uploadthing/react@7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))':
|
||||
'@uploadthing/react@7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))':
|
||||
dependencies:
|
||||
'@uploadthing/shared': 7.0.3
|
||||
file-selector: 0.6.0
|
||||
react: 18.3.1
|
||||
uploadthing: 7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1)
|
||||
uploadthing: 7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1)
|
||||
optionalDependencies:
|
||||
next: 14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)
|
||||
|
||||
@@ -31310,7 +31421,7 @@ snapshots:
|
||||
|
||||
'@vercel/oidc@3.0.3': {}
|
||||
|
||||
'@vercel/oidc@3.1.0': {}
|
||||
'@vercel/oidc@3.0.5': {}
|
||||
|
||||
'@vercel/otel@1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))':
|
||||
dependencies:
|
||||
@@ -31330,6 +31441,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- utf-8-validate
|
||||
|
||||
'@vercel/sdk@1.19.1':
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- '@cfworker/json-schema'
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@3.1.4(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
@@ -31739,11 +31858,11 @@ snapshots:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
ai@6.0.39(zod@3.25.76):
|
||||
ai@6.0.3(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.16(zod@3.25.76)
|
||||
'@ai-sdk/provider': 3.0.4
|
||||
'@ai-sdk/provider-utils': 4.0.8(zod@3.25.76)
|
||||
'@ai-sdk/gateway': 3.0.2(zod@3.25.76)
|
||||
'@ai-sdk/provider': 3.0.0
|
||||
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
@@ -32170,6 +32289,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.0
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
qs: 6.14.1
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
bottleneck@2.19.5: {}
|
||||
|
||||
bowser@2.11.0: {}
|
||||
@@ -33158,6 +33291,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 10.0.0
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize-keys@1.1.1:
|
||||
dependencies:
|
||||
decamelize: 1.2.0
|
||||
@@ -33515,7 +33652,7 @@ snapshots:
|
||||
enhanced-resolve@5.18.3:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.2.2
|
||||
tapable: 2.3.0
|
||||
|
||||
enquirer@2.3.6:
|
||||
dependencies:
|
||||
@@ -34393,6 +34530,11 @@ snapshots:
|
||||
dependencies:
|
||||
express: 5.0.1(supports-color@10.0.0)
|
||||
|
||||
express-rate-limit@8.2.1(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
ip-address: 10.0.1
|
||||
|
||||
express@4.20.0:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
@@ -34466,6 +34608,39 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.2.2
|
||||
content-disposition: 1.0.0
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.1
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.1(supports-color@10.0.0)
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 2.1.0(supports-color@10.0.0)
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.0
|
||||
merge-descriptors: 2.0.0
|
||||
mime-types: 3.0.0
|
||||
on-finished: 2.4.1
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.14.1
|
||||
range-parser: 1.2.1
|
||||
router: 2.2.0
|
||||
send: 1.1.0(supports-color@10.0.0)
|
||||
serve-static: 2.2.1
|
||||
statuses: 2.0.1
|
||||
type-is: 2.0.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
exsolve@1.0.7: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
@@ -35301,6 +35476,8 @@ snapshots:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
hono@4.11.8: {}
|
||||
|
||||
hono@4.5.11: {}
|
||||
|
||||
hosted-git-info@2.8.9: {}
|
||||
@@ -35342,6 +35519,14 @@ snapshots:
|
||||
statuses: 2.0.1
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -35393,6 +35578,10 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
icss-utils@5.1.0(postcss@8.4.35):
|
||||
dependencies:
|
||||
postcss: 8.4.35
|
||||
@@ -35507,6 +35696,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ip-address@10.0.1: {}
|
||||
|
||||
ip-address@9.0.5:
|
||||
dependencies:
|
||||
jsbn: 1.1.0
|
||||
@@ -37070,6 +37261,8 @@ snapshots:
|
||||
|
||||
mime-db@1.53.0: {}
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
@@ -37078,6 +37271,10 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.53.0
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
mime@2.6.0: {}
|
||||
@@ -38790,6 +38987,13 @@ snapshots:
|
||||
iconv-lite: 0.6.3
|
||||
unpipe: 1.0.0
|
||||
|
||||
raw-body@3.0.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
rc9@2.1.2:
|
||||
dependencies:
|
||||
defu: 6.1.4
|
||||
@@ -39705,6 +39909,16 @@ snapshots:
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 8.2.0
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.1(supports-color@10.0.0)
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 8.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
rtl-css-js@1.16.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
@@ -39866,6 +40080,22 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
mime-types: 3.0.2
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.2.1
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serialize-javascript@6.0.1:
|
||||
dependencies:
|
||||
randombytes: 2.1.0
|
||||
@@ -39892,6 +40122,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serve-static@2.2.1:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
set-cookie-parser@2.6.0: {}
|
||||
@@ -40313,6 +40552,8 @@ snapshots:
|
||||
|
||||
statuses@2.0.1: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@3.7.0: {}
|
||||
|
||||
std-env@3.8.1: {}
|
||||
@@ -40743,8 +40984,6 @@ snapshots:
|
||||
|
||||
tapable@2.2.1: {}
|
||||
|
||||
tapable@2.2.2: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tar-fs@2.1.3:
|
||||
@@ -41262,6 +41501,12 @@ snapshots:
|
||||
media-typer: 1.1.0
|
||||
mime-types: 3.0.0
|
||||
|
||||
type-is@2.0.1:
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
media-typer: 1.1.0
|
||||
mime-types: 3.0.0
|
||||
|
||||
typed-array-buffer@1.0.2:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -41472,7 +41717,7 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uploadthing@7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1):
|
||||
uploadthing@7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1):
|
||||
dependencies:
|
||||
'@effect/platform': 0.63.2(@effect/schema@0.72.2(effect@3.7.2))(effect@3.7.2)
|
||||
'@effect/schema': 0.72.2(effect@3.7.2)
|
||||
@@ -41480,7 +41725,7 @@ snapshots:
|
||||
'@uploadthing/shared': 7.0.3
|
||||
effect: 3.7.2
|
||||
optionalDependencies:
|
||||
express: 5.0.1(supports-color@10.0.0)
|
||||
express: 5.2.1
|
||||
fastify: 5.4.0
|
||||
next: 14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)
|
||||
tailwindcss: 3.4.1
|
||||
@@ -42118,6 +42363,10 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-validation-error@1.5.0(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
Reference in New Issue
Block a user