From 103456bd3f980531f6ff263fb3b54c9504d7e8a9 Mon Sep 17 00:00:00 2001 From: gary-ix Date: Thu, 25 Sep 2025 11:39:17 -0500 Subject: [PATCH] feat: preview-create becomes preview-deploy to allow updating of preview environments rather than always creating new ones --- src/cli/deploy.ts | 140 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 119 insertions(+), 21 deletions(-) diff --git a/src/cli/deploy.ts b/src/cli/deploy.ts index 254ce76e..4b036f1d 100644 --- a/src/cli/deploy.ts +++ b/src/cli/deploy.ts @@ -17,6 +17,9 @@ import { CONVEX_SELF_HOSTED_URL_VAR_NAME, CONVEX_DEPLOYMENT_ENV_VAR_NAME, bigBrainAPI, + bigBrainAPIMaybeThrows, + logAndHandleFetchError, + ThrowingFetchError, } from "./lib/utils/utils.js"; import { runFunctionAndLog } from "./lib/run.js"; import { usageStateWarning } from "./lib/usage.js"; @@ -27,6 +30,49 @@ import { deployToDeployment, runCommand } from "./lib/deploy2.js"; import { getDeploymentSelection } from "./lib/deploymentSelection.js"; import { deploymentNameAndTypeFromSelection } from "./lib/deploymentSelection.js"; import { checkVersion } from "./lib/updates.js"; + +async function checkPreviewDeploymentExists( + ctx: Context, + previewName: string, + projectSelection: { + kind: "teamAndProjectSlugs"; + teamSlug: string; + projectSlug: string; + }, +): Promise<{ + adminKey: string; + url: string; + deploymentName: string; +} | null> { + try { + const result = await bigBrainAPIMaybeThrows({ + ctx, + method: "POST", + url: "deployment/authorize_preview", + data: { + previewName: previewName, + projectSelection: projectSelection, + }, + }); + return { + adminKey: result.adminKey, + url: result.url, + deploymentName: result.deploymentName, + }; + } catch (err) { + // Only return null for the specific "PreviewNotFound" code + const isNotFoundError = + err instanceof ThrowingFetchError && + err.response.status === 404 && + err.serverErrorData?.code === "PreviewNotFound"; + if (isNotFoundError) { + return null; + } + + return await logAndHandleFetchError(ctx, err); + } +} + export const deploy = new Command("deploy") .summary("Deploy to your prod deployment") .description( @@ -43,8 +89,8 @@ export const deploy = new Command("deploy") ) .addOption( new Option( - "--preview-create ", - "The name to associate with this deployment if deploying to a newly created preview deployment. Defaults to the current Git branch name in Vercel, Netlify and GitHub CI. This is ignored if deploying to a production deployment.", + "--preview-deploy ", + "The name to associate with this deployment if deploying to a preview deployment. Creates a new preview deployment if it doesn't exist, or updates an existing one. Defaults to the current Git branch name in Vercel, Netlify and GitHub CI. This is ignored if deploying to a production deployment.", ).conflicts("preview-name"), ) .addOption( @@ -59,13 +105,21 @@ export const deploy = new Command("deploy") // Hidden options to pass in admin key and url for tests and local development .addOption(new Option("--admin-key ").hideHelp()) .addOption(new Option("--url ").hideHelp()) + .addOption( + new Option( + "--preview-create ", + "[deprecated] Use `--preview-deploy` instead. The name to associate with this deployment if deploying to a newly created preview deployment.", + ) + .hideHelp() + .conflicts("preview-deploy"), + ) .addOption( new Option( "--preview-name ", - "[deprecated] Use `--preview-create` instead. The name to associate with this deployment if deploying to a preview deployment.", + "[deprecated] Use `--preview-deploy` instead. The name to associate with this deployment if deploying to a preview deployment.", ) .hideHelp() - .conflicts("preview-create"), + .conflicts("preview-deploy"), ) .addOption( new Option( @@ -113,12 +167,31 @@ Same format as .env.local or .env files, and overrides them.`, if (deploymentSelection.kind === "preview") { // TODO -- add usage state warnings here too once we can do it without a deployment name // await usageStateWarning(ctx); + if (cmdOptions.previewCreate !== undefined) { + await ctx.crash({ + exitCode: 1, + errorType: "fatal", + printedMessage: + "The `--preview-create` flag has been deprecated in favor of `--preview-deploy`. Please re-run the command using `--preview-deploy` instead.", + }); + } if (cmdOptions.previewName !== undefined) { await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: - "The `--preview-name` flag has been deprecated in favor of `--preview-create`. Please re-run the command using `--preview-create` instead.", + "The `--preview-name` flag has been deprecated in favor of `--preview-deploy`. Please re-run the command using `--preview-deploy` instead.", + }); + } + + const previewName = + cmdOptions.previewDeploy ?? gitBranchFromEnvironment(); + if (previewName === null) { + await ctx.crash({ + exitCode: 1, + errorType: "fatal", + printedMessage: + "`npx convex deploy` to a preview deployment could not determine the preview name. Provide one using `--preview-deploy`", }); } @@ -126,26 +199,51 @@ Same format as .env.local or .env files, and overrides them.`, ctx, deploymentSelection.previewDeployKey, ); - await deployToNewPreviewDeployment( + + const existingPreview = await checkPreviewDeploymentExists( ctx, + previewName!, // checked above { - previewDeployKey: deploymentSelection.previewDeployKey, - projectSelection: { - kind: "teamAndProjectSlugs", - teamSlug: teamAndProjectSlugs.teamSlug, - projectSlug: teamAndProjectSlugs.projectSlug, - }, - }, - { - ...cmdOptions, + kind: "teamAndProjectSlugs", + teamSlug: teamAndProjectSlugs.teamSlug, + projectSlug: teamAndProjectSlugs.projectSlug, }, ); + + if (existingPreview) { + logMessage( + `Preview deployment "${previewName}" exists. Updating deployment.`, + ); + await deployToExistingDeployment(ctx, { + ...cmdOptions, + adminKey: existingPreview.adminKey, + url: existingPreview.url, + }); + } else { + logMessage( + `Preview deployment "${previewName}" does not exist. Creating new deployment.`, + ); + await deployToPreviewDeployment( + ctx, + { + previewDeployKey: deploymentSelection.previewDeployKey, + projectSelection: { + kind: "teamAndProjectSlugs", + teamSlug: teamAndProjectSlugs.teamSlug, + projectSlug: teamAndProjectSlugs.projectSlug, + }, + }, + { + ...cmdOptions, + }, + ); + } } else { await deployToExistingDeployment(ctx, cmdOptions); } }); -async function deployToNewPreviewDeployment( +async function deployToPreviewDeployment( ctx: Context, deploymentSelection: { previewDeployKey: string; @@ -157,7 +255,7 @@ async function deployToNewPreviewDeployment( }, options: { dryRun?: boolean | undefined; - previewCreate?: string | undefined; + previewDeploy?: string | undefined; previewRun?: string | undefined; cmdUrlEnvVarName?: string | undefined; cmd?: string | undefined; @@ -170,19 +268,19 @@ async function deployToNewPreviewDeployment( debugBundlePath?: string | undefined; }, ) { - const previewName = options.previewCreate ?? gitBranchFromEnvironment(); + const previewName = options.previewDeploy ?? gitBranchFromEnvironment(); if (previewName === null) { await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: - "`npx convex deploy` to a preview deployment could not determine the preview name. Provide one using `--preview-create`", + "`npx convex deploy` to a preview deployment could not determine the preview name. Provide one using `--preview-deploy`", }); } if (options.dryRun) { logFinishedStep( - `Would have claimed preview deployment for "${previewName}"`, + `Would have deployed to preview deployment "${previewName}"`, ); await runCommand(ctx, { cmdUrlEnvVarName: options.cmdUrlEnvVarName, @@ -192,7 +290,7 @@ async function deployToNewPreviewDeployment( adminKey: "preview-deployment-admin-key", }); logFinishedStep( - `Would have deployed Convex functions to preview deployment for "${previewName}"`, + `Would have deployed Convex functions to preview deployment "${previewName}"`, ); if (options.previewRun !== undefined) { logMessage(`Would have run function "${options.previewRun}"`);