diff --git a/.changeset/list-project-runtime-updates.md b/.changeset/list-project-runtime-updates.md new file mode 100644 index 0000000000..fe8c747976 --- /dev/null +++ b/.changeset/list-project-runtime-updates.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +List the current Production runtime for every accessible project with `trigger projects list`. Add `--needs-update` to identify projects currently running Node.js 21. diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 465346ad15..11b516e8d7 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon } from "@heroicons/react/24/solid"; +import { ArrowLeftIcon, ArrowPathIcon } from "@heroicons/react/24/solid"; import { BellIcon } from "~/assets/icons/BellIcon"; import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; @@ -16,6 +16,7 @@ import { cn } from "~/utils/cn"; import { organizationPath, organizationRolesPath, + organizationRuntimeUpdatesPath, organizationSettingsPath, organizationSlackIntegrationPath, organizationSsoPath, @@ -127,6 +128,14 @@ export function OrganizationSettingsSideMenu({ ) : null} )} + { const user = await requireUser(request); const { pathname, search } = new URL(request.url); - const page = resolveDeeplinkPage(deeplinkSuffix(pathname)); + const suffix = deeplinkSuffix(pathname); + const page = resolveDeeplinkPage(suffix); + const organizationPage = resolveOrganizationDeeplinkPage(suffix); const invites = await getUsersInvites({ email: user.email }); if (invites.length > 0) { @@ -26,11 +33,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const presenter = new SelectBestEnvironmentPresenter(); try { const { project, organization, environment } = await presenter.call({ user }); - const environmentPath = v3EnvironmentPath(organization, project, environment); + if (organizationPage === "runtime-updates") { + return redirect(`${organizationRuntimeUpdatesPath(organization)}${search}`); + } - const suffix = page ? `/${page}` : ""; + const environmentPath = v3EnvironmentPath(organization, project, environment); + const pageSuffix = page ? `/${page}` : ""; - return redirect(`${environmentPath}${suffix}${search}`); + return redirect(`${environmentPath}${pageSuffix}${search}`); } catch (_e) { const organization = await prisma.organization.findFirst({ where: { @@ -47,6 +57,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }); if (organization) { + if (organizationPage === "runtime-updates") { + return redirect(`${organizationRuntimeUpdatesPath(organization)}${search}`); + } + return redirect(newProjectPath(organization)); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.runtime-updates/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.runtime-updates/route.tsx new file mode 100644 index 0000000000..10ed9f327f --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.runtime-updates/route.tsx @@ -0,0 +1,138 @@ +import { ArrowUpRightIcon } from "@heroicons/react/20/solid"; +import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { RuntimeIcon } from "~/components/RuntimeIcon"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Header2 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { listCurrentProductionProjectRuntimes } from "~/services/projectRuntimeUpdates.server"; +import { OrganizationParamsSchema, v3DeploymentsPath } from "~/utils/pathBuilder"; +import { pageMeta } from "~/utils/pageTitle"; + +export const meta = pageMeta("Runtime updates"); + +export const loader = dashboardLoader( + { + params: OrganizationParamsSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { + action: "read", + resource: { type: "deployments" }, + message: "With your current role, you can't view runtime updates.", + }, + }, + async ({ context, params }) => { + const runtimes = await listCurrentProductionProjectRuntimes({ + organizationId: context.organizationId, + }); + + return typedjson({ + organizationSlug: params.organizationSlug, + runtimes: runtimes.filter( + (runtime) => runtime.deployment?.nodeMajor === NODE_RUNTIME_UPDATE_MAJOR + ), + }); + } +); + +export default function Page() { + const { organizationSlug, runtimes } = useTypedLoaderData(); + + return ( + + + +
+

+ Runtime update available +

+ Move Production projects to Node.js 24 + + {runtimes.length} {runtimes.length === 1 ? "project is" : "projects are"} currently + running Node.js {NODE_RUNTIME_UPDATE_MAJOR} in Production. Update every project listed + below. + +
+ + {runtimes.length === 0 ? ( +
+ Everything is up to date + + No Production projects are currently using Node.js {NODE_RUNTIME_UPDATE_MAJOR}. + +
+ ) : ( +
+
+

Update every listed project

+ + Update each project listed below in its trigger.config.ts, then + deploy a new Production version. + +
+                  {`export default defineConfig({\n  project: "",\n  runtime: "node-24",\n});`}
+                
+ + Prefer the command line? Run{" "} + npx trigger.dev@latest projects list --needs-update to find projects + that need an update. + +
+ +
+ {runtimes.map(({ project, environment, deployment }) => { + if (!deployment) return null; + + return ( +
+
+

{project.name}

+

+ {project.externalRef} +

+
+ + + Production deployed {deployment.deployedAt?.toLocaleString() ?? "-"} + +
+
+ + View deployment + +
+ ); + })} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/api.v1.projects.runtimes.ts b/apps/webapp/app/routes/api.v1.projects.runtimes.ts new file mode 100644 index 0000000000..b88fc5d562 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.runtimes.ts @@ -0,0 +1,17 @@ +import { json } from "@remix-run/server-runtime"; +import type { GetProjectRuntimesResponseBody } from "@trigger.dev/core/v3"; +import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { listCurrentProductionProjectRuntimes } from "~/services/projectRuntimeUpdates.server"; + +// Identity-only: like /api/v1/projects, this returns resources across every organization the PAT +// owner belongs to. Runtime details are limited to each project's current Production deployment. +export const loader = createLoaderPATApiRoute( + { identityOnly: true }, + async ({ authentication }) => { + const runtimes: GetProjectRuntimesResponseBody = await listCurrentProductionProjectRuntimes({ + userId: authentication.userId, + }); + + return json(runtimes); + } +); diff --git a/apps/webapp/app/services/projectRuntimeUpdates.server.ts b/apps/webapp/app/services/projectRuntimeUpdates.server.ts new file mode 100644 index 0000000000..ad5d5ef349 --- /dev/null +++ b/apps/webapp/app/services/projectRuntimeUpdates.server.ts @@ -0,0 +1,84 @@ +import { nodeMajor } from "@trigger.dev/core/v3"; +import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic"; +import { prisma } from "~/db.server"; + +type Options = { + organizationId?: string; + userId?: string; +}; + +export async function listCurrentProductionProjectRuntimes({ organizationId, userId }: Options) { + const projects = await prisma.project.findMany({ + where: { + ...(organizationId ? { organizationId } : {}), + ...(userId + ? { + organization: { + deletedAt: null, + members: { some: { userId } }, + }, + } + : {}), + version: "V3", + deletedAt: null, + }, + select: { + name: true, + slug: true, + externalRef: true, + organization: { + select: { + title: true, + slug: true, + }, + }, + environments: { + where: { type: "PRODUCTION" }, + select: { + slug: true, + workerDeploymentPromotions: { + where: { label: CURRENT_DEPLOYMENT_LABEL }, + select: { + deployment: { + select: { + runtime: true, + runtimeVersion: true, + deployedAt: true, + shortCode: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: [{ organization: { title: "asc" } }, { name: "asc" }], + }); + + return projects.flatMap((project) => + project.environments.map((environment) => { + const deployment = environment.workerDeploymentPromotions[0]?.deployment; + + return { + organization: project.organization, + project: { + name: project.name, + slug: project.slug, + externalRef: project.externalRef, + }, + environment: { + slug: environment.slug, + }, + deployment: deployment + ? { + runtime: deployment.runtime, + runtimeVersion: deployment.runtimeVersion, + nodeMajor: nodeMajor(deployment.runtime, deployment.runtimeVersion) ?? null, + deployedAt: deployment.deployedAt, + shortCode: deployment.shortCode, + } + : null, + }; + }) + ); +} diff --git a/apps/webapp/app/utils/deeplinkPages.test.ts b/apps/webapp/app/utils/deeplinkPages.test.ts index 0d7e473753..5c3800c91c 100644 --- a/apps/webapp/app/utils/deeplinkPages.test.ts +++ b/apps/webapp/app/utils/deeplinkPages.test.ts @@ -8,7 +8,9 @@ import { DEEPLINK_PATH_PREFIX, deeplinkSuffix, ENV_PAGE_TARGETS, + ORG_PAGE_TARGETS, resolveDeeplinkPage, + resolveOrganizationDeeplinkPage, } from "./deeplinkPages"; const APP_DIR = join(__dirname, ".."); @@ -161,6 +163,15 @@ describe("resolveDeeplinkPage", () => { expect(resolveDeeplinkPage("tasks")).toBe(""); }); + it("resolves organization-level pages separately from environment pages", () => { + expect(ORG_PAGE_TARGETS.get("runtime-updates")).toEqual({ + landing: "runtime-updates", + prefix: "runtime-updates", + }); + expect(resolveOrganizationDeeplinkPage("runtime-updates")).toBe("runtime-updates"); + expect(resolveDeeplinkPage("runtime-updates")).toBeUndefined(); + }); + it("grafts deeper segments onto the prefix", () => { expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123"); expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task"); diff --git a/apps/webapp/app/utils/deeplinkPages.ts b/apps/webapp/app/utils/deeplinkPages.ts index 898816f25e..9a03ef386e 100644 --- a/apps/webapp/app/utils/deeplinkPages.ts +++ b/apps/webapp/app/utils/deeplinkPages.ts @@ -38,6 +38,10 @@ export const ENV_PAGE_TARGETS: ReadonlyMap = new Map([ ["webhooks", page("webhooks")], ]); +export const ORG_PAGE_TARGETS: ReadonlyMap = new Map([ + ["runtime-updates", page("runtime-updates")], +]); + export const DEEPLINK_PATH_PREFIX = "/_"; export function deeplinkSuffix(pathname: string): string { @@ -61,11 +65,14 @@ function isSafeSegment(segment: string): boolean { return decoded !== "." && decoded !== ".."; } -export function resolveDeeplinkPage(suffix: string): string | undefined { +function resolveDeeplinkTarget( + targets: ReadonlyMap, + suffix: string +): string | undefined { const segments = suffix.split("/").filter(isSafeSegment); const [first = "", ...rest] = segments; - const target = ENV_PAGE_TARGETS.get(first.toLowerCase()); + const target = targets.get(first.toLowerCase()); if (target === undefined) return undefined; if (rest.length === 0) return target.landing; @@ -76,3 +83,11 @@ export function resolveDeeplinkPage(suffix: string): string | undefined { return [target.prefix, ...beyondPrefix].join("/"); } + +export function resolveDeeplinkPage(suffix: string): string | undefined { + return resolveDeeplinkTarget(ENV_PAGE_TARGETS, suffix); +} + +export function resolveOrganizationDeeplinkPage(suffix: string): string | undefined { + return resolveDeeplinkTarget(ORG_PAGE_TARGETS, suffix); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index bd8cf152b7..1db150a368 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -170,6 +170,10 @@ export function organizationSettingsPath(organization: OrgForPath) { return `${organizationPath(organization)}/settings`; } +export function organizationRuntimeUpdatesPath(organization: OrgForPath) { + return `${organizationSettingsPath(organization)}/runtime-updates`; +} + function organizationIntegrationsPath(organization: OrgForPath) { return `${organizationPath(organization)}/settings/integrations`; } diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index baaecc7924..fba2e52e1e 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -29,6 +29,7 @@ import { GetPersonalAccessTokenResponseSchema, GetProjectEnvResponse, GetProjectResponseBody, + GetProjectRuntimesResponseBody, GetProjectsResponseBody, InitializeDeploymentResponseBody, PromoteDeploymentResponseBody, @@ -215,6 +216,19 @@ export class CliApiClient { }); } + async getProjectRuntimes() { + if (!this.accessToken) { + throw new Error("getProjectRuntimes: No access token"); + } + + return wrapZodFetch(GetProjectRuntimesResponseBody, `${this.apiURL}/api/v1/projects/runtimes`, { + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + }); + } + async getOrgs() { if (!this.accessToken) { throw new Error("getOrgs: No access token"); diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts index fc3958fb1d..5dec4214ba 100644 --- a/packages/cli-v3/src/cli/index.ts +++ b/packages/cli-v3/src/cli/index.ts @@ -8,6 +8,7 @@ import { configureListProfilesCommand } from "../commands/list-profiles.js"; import { configureLoginCommand } from "../commands/login.js"; import { configureLogoutCommand } from "../commands/logout.js"; import { configurePreviewCommand } from "../commands/preview.js"; +import { configureProjectsCommand } from "../commands/projects/index.js"; import { configurePromoteCommand } from "../commands/promote.js"; import { configureSwitchProfilesCommand } from "../commands/switch.js"; import { configureUpdateCommand } from "../commands/update.js"; @@ -41,6 +42,7 @@ configureListProfilesCommand(program); configureSwitchProfilesCommand(program); configureUpdateCommand(program); configurePreviewCommand(program); +configureProjectsCommand(program); configureAnalyzeCommand(program); configureMcpCommand(program); configureReportCommand(program); diff --git a/packages/cli-v3/src/commands/projects/index.ts b/packages/cli-v3/src/commands/projects/index.ts new file mode 100644 index 0000000000..6e677a07b4 --- /dev/null +++ b/packages/cli-v3/src/commands/projects/index.ts @@ -0,0 +1,10 @@ +import type { Command } from "commander"; +import { configureProjectsListCommand } from "./list.js"; + +export function configureProjectsCommand(program: Command) { + const projects = program.command("projects").description("Manage Trigger.dev projects"); + + configureProjectsListCommand(projects); + + return projects; +} diff --git a/packages/cli-v3/src/commands/projects/list.ts b/packages/cli-v3/src/commands/projects/list.ts new file mode 100644 index 0000000000..8bd4b8bb03 --- /dev/null +++ b/packages/cli-v3/src/commands/projects/list.ts @@ -0,0 +1,95 @@ +import { intro, outro } from "@clack/prompts"; +import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3"; +import type { Command } from "commander"; +import { z } from "zod"; +import { CliApiClient } from "../../apiClient.js"; +import { + CommonCommandOptions, + commonOptions, + handleTelemetry, + wrapCommandAction, +} from "../../cli/common.js"; +import { printStandloneInitialBanner } from "../../utilities/initialBanner.js"; +import { logger } from "../../utilities/logger.js"; +import { login } from "../login.js"; + +const ProjectsListCommandOptions = CommonCommandOptions.extend({ + needsUpdate: z.boolean().default(false), +}); + +type ProjectsListCommandOptions = z.infer; + +export function configureProjectsListCommand(program: Command) { + return commonOptions( + program + .command("list") + .description("List current Production deployment runtimes for your projects") + .option( + "--needs-update", + `Only show projects using Node.js ${NODE_RUNTIME_UPDATE_MAJOR} in Production` + ) + .action(async (options) => { + await handleTelemetry(async () => { + await printStandloneInitialBanner(true, options.profile); + await projectsListCommand(options); + }); + }) + ); +} + +async function projectsListCommand(options: unknown) { + return await wrapCommandAction( + "projectsListCommand", + ProjectsListCommandOptions, + options, + async (opts) => await listProjects(opts) + ); +} + +async function listProjects(options: ProjectsListCommandOptions) { + intro("Listing current Production deployment runtimes"); + + const authorization = await login({ + embedded: true, + defaultApiUrl: options.apiUrl, + profile: options.profile, + silent: true, + }); + + if (!authorization.ok) { + throw new Error( + `You must login first. Use the \`login\` CLI command.\n\n${authorization.error}` + ); + } + + const apiClient = new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken); + const response = await apiClient.getProjectRuntimes(); + + if (!response.success) { + throw new Error(`Failed to list projects: ${response.error}`); + } + + const projects = options.needsUpdate + ? response.data.filter((project) => project.deployment?.nodeMajor === NODE_RUNTIME_UPDATE_MAJOR) + : response.data; + + if (projects.length === 0) { + outro( + options.needsUpdate + ? `No Production projects using Node.js ${NODE_RUNTIME_UPDATE_MAJOR} found.` + : "No Production projects found." + ); + return; + } + + logger.table( + projects.map(({ organization, project, deployment }) => ({ + organization: organization.title, + project: project.name, + ref: project.externalRef, + runtime: deployment?.runtime ?? "Not deployed", + version: deployment?.runtimeVersion ?? "-", + "deployed at": deployment?.deployedAt?.toLocaleString() ?? "-", + })) + ); +} diff --git a/packages/core/src/v3/schemas/api-type.test.ts b/packages/core/src/v3/schemas/api-type.test.ts index 39fb3131ce..062bc7bd96 100644 --- a/packages/core/src/v3/schemas/api-type.test.ts +++ b/packages/core/src/v3/schemas/api-type.test.ts @@ -1,7 +1,26 @@ import { describe, it, expect } from "vitest"; -import { BatchItemNDJSON, InitializeDeploymentRequestBody, TriggerTaskRequestBody } from "./api.js"; +import { + BatchItemNDJSON, + InitializeDeploymentRequestBody, + nodeMajor, + TriggerTaskRequestBody, +} from "./api.js"; import type { InitializeDeploymentRequestBody as InitializeDeploymentRequestBodyType } from "./api.js"; +describe("nodeMajor", () => { + it.each([ + ["node", "20.18.0", 20], + ["node", "21.7.3", 21], + ["node-22", "22.16.0", 22], + ["node-24", "24.18.0", 24], + ["bun", "1.3.3", undefined], + ["node", null, undefined], + ["node", "unknown", undefined], + ])("resolves %s %s", (runtime, runtimeVersion, expected) => { + expect(nodeMajor(runtime, runtimeVersion)).toBe(expected); + }); +}); + describe("InitializeDeploymentRequestBody", () => { const base = { contentHash: "abc123" }; diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 12e991cfef..bdb8cdcedf 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -59,6 +59,53 @@ export const GetProjectsResponseBody = z.array(GetProjectResponseBody); export type GetProjectsResponseBody = z.infer; +/** The Node.js major version currently targeted by the runtime update report. */ +export const NODE_RUNTIME_UPDATE_MAJOR = 21; + +/** + * Returns the observed Node.js major version for a deployment. + * + * `runtime: "node"` is an alias whose underlying Node.js version has changed over time, so the + * recorded runtimeVersion is deliberately the source of truth here. + */ +export function nodeMajor( + runtime: string | null | undefined, + runtimeVersion: string | null | undefined +) { + if (!runtime?.startsWith("node")) return undefined; + + const match = runtimeVersion?.match(/^(\d+)(?:\.\d+){1,2}(?:[-+].*)?$/); + return match ? Number(match[1]) : undefined; +} + +export const GetProjectRuntimesResponseBody = z.array( + z.object({ + organization: z.object({ + title: z.string(), + slug: z.string(), + }), + project: z.object({ + name: z.string(), + slug: z.string(), + externalRef: z.string(), + }), + environment: z.object({ + slug: z.string(), + }), + deployment: z + .object({ + runtime: z.string().nullable(), + runtimeVersion: z.string().nullable(), + nodeMajor: z.number().int().positive().nullable(), + deployedAt: z.coerce.date().nullable(), + shortCode: z.string(), + }) + .nullable(), + }) +); + +export type GetProjectRuntimesResponseBody = z.infer; + export const GetOrgsResponseBody = z.array( z.object({ id: z.string(),