UNPKG

convex

Version:

Client for the Convex Cloud

973 lines (932 loc) 28.3 kB
import { Context } from "../../bundler/context.js"; import { logVerbose, logWarning } from "../../bundler/log.js"; import { getTeamAndProjectFromPreviewAdminKey } from "./deployment.js"; import { assertLocalBackendRunning, localDeploymentUrl, } from "./localDeployment/run.js"; import { ThrowingFetchError, bigBrainAPI, bigBrainAPIMaybeThrows, logAndHandleFetchError, typedPlatformClient, } from "./utils/utils.js"; import { z } from "zod"; import { DeploymentSelection, ProjectSelection, } from "./deploymentSelection.js"; import { loadLocalDeploymentCredentials } from "./localDeployment/localDeployment.js"; import { loadAnonymousDeployment } from "./localDeployment/anonymous.js"; import { parseDeploymentSelector, InProjectSelector, } from "./deploymentSelector.js"; import { chalkStderr } from "chalk"; export type DeploymentName = string; export type CloudDeploymentType = "prod" | "dev" | "preview" | "custom"; export type AccountRequiredDeploymentType = CloudDeploymentType | "local"; export type DeploymentType = AccountRequiredDeploymentType | "anonymous"; export type Project = { id: number; name: string; slug: string; isDemo: boolean; }; type AdminKey = string; /** * Create a new project. If `deploymentToProvision` is specified, also provision a deployment for the project. */ export async function createProject( ctx: Context, { teamSlug: selectedTeamSlug, projectName, deploymentToProvision, }: { teamSlug: string; projectName: string; deploymentToProvision: { deploymentType: "prod" | "dev"; region: string | null; } | null; }, ): Promise<{ projectSlug: string; teamSlug: string; projectsRemaining: number; }> { const provisioningArgs = { team: selectedTeamSlug, projectName, ...deploymentToProvision, }; const data = await bigBrainAPI({ ctx, method: "POST", path: "create_project", data: provisioningArgs, }); const { projectSlug, teamSlug, projectsRemaining } = data; if ( projectSlug === undefined || teamSlug === undefined || projectsRemaining === undefined ) { const error = "Unexpected response during provisioning: " + JSON.stringify(data); return await ctx.crash({ exitCode: 1, errorType: "transient", errForSentry: error, printedMessage: error, }); } return { projectSlug, teamSlug, projectsRemaining, }; } // ---------------------------------------------------------------------- // Helpers for `deploymentSelectionFromOptions` // ---------------------------------------------------------------------- export const deploymentSelectionWithinProjectSchema = z.discriminatedUnion( "kind", [ z.object({ kind: z.literal("previewName"), previewName: z.string() }), z.object({ kind: z.literal("deploymentName"), deploymentName: z.string() }), z.object({ kind: z.literal("prod") }), z.object({ kind: z.literal("implicitProd") }), z.object({ kind: z.literal("ownDev") }), z.object({ kind: z.literal("deploymentSelector"), selector: z.string(), }), ], ); export type DeploymentSelectionWithinProject = z.infer< typeof deploymentSelectionWithinProjectSchema >; type DeploymentSelectionOptionsWithinProject = { prod?: boolean | undefined; // Whether this command defaults to prod when no other flags are provided. If // this is not set, the default will be "ownDev" implicitProd?: boolean; previewName?: string | undefined; deploymentName?: string | undefined; deployment?: string | undefined; }; export type DeploymentSelectionOptions = DeploymentSelectionOptionsWithinProject & { url?: string | undefined; adminKey?: string | undefined; envFile?: string | undefined; }; export function deploymentSelectionWithinProjectFromOptions( options: DeploymentSelectionOptions, ): DeploymentSelectionWithinProject { if (options.deployment !== undefined) { return { kind: "deploymentSelector", selector: options.deployment }; } if (options.previewName !== undefined) { return { kind: "previewName", previewName: options.previewName }; } if (options.deploymentName !== undefined) { return { kind: "deploymentName", deploymentName: options.deploymentName }; } if (options.prod) { return { kind: "prod" }; } if (options.implicitProd) { return { kind: "implicitProd" }; } return { kind: "ownDev" }; } export async function validateDeploymentSelectionForExistingDeployment( ctx: Context, deploymentSelection: DeploymentSelectionWithinProject, source: "selfHosted" | "deployKey" | "cliArgs", ) { if ( deploymentSelection.kind === "ownDev" || deploymentSelection.kind === "implicitProd" ) { // These are both considered the "default" selection depending on the command, so this is always fine return; } if (deploymentSelection.kind === "deploymentSelector") { switch (source) { case "selfHosted": return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "The `--deployment` flag cannot be used with a self-hosted deployment.", }); case "deployKey": return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "The `--deployment` flag cannot be used with CONVEX_DEPLOY_KEY.", }); case "cliArgs": return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "The `--deployment` flag cannot be used with --url and --admin-key.", }); } } switch (source) { case "selfHosted": return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "The `--prod`, `--preview-name`, and `--deployment-name` flags cannot be used with a self-hosted deployment.", }); case "deployKey": logWarning( "Ignoring `--prod`, `--preview-name`, or `--deployment-name` flags and using deployment from CONVEX_DEPLOY_KEY", ); break; case "cliArgs": logWarning( "Ignoring `--prod`, `--preview-name`, or `--deployment-name` flags since this command was run with --url and --admin-key", ); break; } } // ---------------------------------------------------------------------- // Helpers for `checkAccessToSelectedProject` // ---------------------------------------------------------------------- async function hasAccessToProject( ctx: Context, selector: { projectSlug: string; teamSlug: string }, ): Promise<boolean> { try { await bigBrainAPIMaybeThrows({ ctx, path: `teams/${selector.teamSlug}/projects/${selector.projectSlug}/deployments`, method: "GET", }); return true; } catch (err) { if ( err instanceof ThrowingFetchError && (err.serverErrorData?.code === "TeamNotFound" || err.serverErrorData?.code === "ProjectNotFound") ) { return false; } return logAndHandleFetchError(ctx, err); } } export async function checkAccessToSelectedProject( ctx: Context, projectSelection: ProjectSelection, ): Promise< | { kind: "hasAccess"; teamSlug: string; projectSlug: string } | { kind: "noAccess" } | { kind: "unknown" } > { switch (projectSelection.kind) { case "deploymentName": { const result = await getTeamAndProjectSlugForDeployment(ctx, { deploymentName: projectSelection.deploymentName, }); if (result === null) { return { kind: "noAccess" }; } return { kind: "hasAccess", teamSlug: result.teamSlug, projectSlug: result.projectSlug, }; } case "teamAndProjectSlugs": { const hasAccess = await hasAccessToProject(ctx, { teamSlug: projectSelection.teamSlug, projectSlug: projectSelection.projectSlug, }); if (!hasAccess) { return { kind: "noAccess" }; } return { kind: "hasAccess", teamSlug: projectSelection.teamSlug, projectSlug: projectSelection.projectSlug, }; } case "projectDeployKey": // Ideally we would be able to do an explicit check here, but if the key is invalid, // it will instead fail as soon as we try to use the key. return { kind: "unknown" }; default: { projectSelection satisfies never; return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `Invalid project selection: ${(projectSelection as any).kind}`, }); } } } export async function getTeamAndProjectSlugForDeployment( ctx: Context, selector: { deploymentName: string }, ): Promise<{ teamSlug: string; projectSlug: string } | null> { try { const body = await bigBrainAPIMaybeThrows({ ctx, path: `deployment/${selector.deploymentName}/team_and_project`, method: "GET", }); return { teamSlug: body.team, projectSlug: body.project }; } catch (err) { if ( err instanceof ThrowingFetchError && (err.serverErrorData?.code === "DeploymentNotFound" || err.serverErrorData?.code === "ProjectNotFound") ) { return null; } return logAndHandleFetchError(ctx, err); } } // ---------------------------------------------------------------------- // Helpers for fetching deployment credentials // ---------------------------------------------------------------------- // Used by dev for upgrade from team and project in convex.json to CONVEX_DEPLOYMENT export async function fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows( ctx: Context, projectSelection: | { kind: "teamAndProjectSlugs"; teamSlug: string; projectSlug: string } | { kind: "projectDeployKey"; projectDeployKey: string }, deploymentType: "prod" | "dev", ): Promise<{ deploymentName: string; deploymentUrl: string; adminKey: AdminKey; }> { if (projectSelection.kind === "projectDeployKey") { const auth = ctx.bigBrainAuth(); const doesAuthMatch = auth !== null && auth.kind === "projectKey" && auth.projectKey === projectSelection.projectDeployKey; if (!doesAuthMatch) { return await ctx.crash({ exitCode: 1, errorType: "fatal", errForSentry: new Error( "Expected project deploy key to match the big brain auth header", ), printedMessage: "Unexpected error when loading the Convex deployment", }); } } let data; try { data = await bigBrainAPIMaybeThrows({ ctx, method: "POST", path: "deployment/provision_and_authorize", data: { teamSlug: projectSelection.kind === "teamAndProjectSlugs" ? projectSelection.teamSlug : null, projectSlug: projectSelection.kind === "teamAndProjectSlugs" ? projectSelection.projectSlug : null, deploymentType: deploymentType === "prod" ? "prod" : "dev", }, }); } catch (error) { const msg = "Unknown error during authorization: " + error; return await ctx.crash({ exitCode: 1, errorType: "transient", errForSentry: new Error(msg), printedMessage: msg, }); } const adminKey = data.adminKey; const url = data.url; const deploymentName = data.deploymentName; if (adminKey === undefined || url === undefined) { const msg = "Unknown error during authorization: " + JSON.stringify(data); return await ctx.crash({ exitCode: 1, errorType: "transient", errForSentry: new Error(msg), printedMessage: msg, }); } return { adminKey, deploymentUrl: url, deploymentName }; } async function fetchExistingDevDeploymentCredentialsOrCrash( ctx: Context, deploymentName: DeploymentName, ): Promise<{ deploymentName: string; adminKey: string; url: string; deploymentType: DeploymentType; }> { const slugs = await fetchTeamAndProject(ctx, deploymentName); const credentials = await fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows( ctx, { kind: "teamAndProjectSlugs", teamSlug: slugs.team, projectSlug: slugs.project, }, "dev", ); return { deploymentName: credentials.deploymentName, adminKey: credentials.adminKey, url: credentials.deploymentUrl, deploymentType: "dev", }; } // ---------------------------------------------------------------------- // Helpers for `loadSelectedDeploymentCredentials` // ---------------------------------------------------------------------- async function handleOwnDev( ctx: Context, projectSelection: ProjectSelection, ): Promise<{ deploymentName: string; adminKey: string; url: string; deploymentType: DeploymentType; }> { switch (projectSelection.kind) { case "deploymentName": { if (projectSelection.deploymentType === "local") { const credentials = await loadLocalDeploymentCredentials( ctx, projectSelection.deploymentName, ); return { deploymentName: projectSelection.deploymentName, adminKey: credentials.adminKey, url: credentials.deploymentUrl, deploymentType: "local", }; } return await fetchExistingDevDeploymentCredentialsOrCrash( ctx, projectSelection.deploymentName, ); } case "teamAndProjectSlugs": case "projectDeployKey": { // Note -- this provisions a dev deployment if one doesn't exist const credentials = await fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows( ctx, projectSelection, "dev", ); return { url: credentials.deploymentUrl, adminKey: credentials.adminKey, deploymentName: credentials.deploymentName, deploymentType: "dev", }; } default: { projectSelection satisfies never; return ctx.crash({ exitCode: 1, errorType: "fatal", // This should be unreachable, so don't bother with a printed message. printedMessage: null, errForSentry: `Unexpected project selection: ${(projectSelection as any).kind}`, }); } } } async function handleProd( ctx: Context, projectSelection: ProjectSelection, ): Promise<{ deploymentName: string; adminKey: string; url: string; deploymentType: "prod"; }> { switch (projectSelection.kind) { case "deploymentName": { const credentials = await bigBrainAPI({ ctx, method: "POST", path: "deployment/authorize_prod", data: { deploymentName: projectSelection.deploymentName, }, }); return credentials; } case "teamAndProjectSlugs": case "projectDeployKey": { const credentials = await fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows( ctx, projectSelection, "prod", ); return { url: credentials.deploymentUrl, adminKey: credentials.adminKey, deploymentName: credentials.deploymentName, deploymentType: "prod", }; } } } async function handlePreview( ctx: Context, previewName: string, projectSelection: ProjectSelection, ): Promise<{ deploymentName: string; adminKey: string; url: string; deploymentType: "preview"; }> { switch (projectSelection.kind) { case "deploymentName": case "teamAndProjectSlugs": return await bigBrainAPI({ ctx, method: "POST", path: "deployment/authorize_preview", data: { previewName: previewName, projectSelection: projectSelection, }, }); case "projectDeployKey": // TODO -- this should be supported return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "Project deploy keys are not supported for preview deployments", }); } } async function handleDeploymentName( ctx: Context, deploymentName: string, projectSelection: ProjectSelection, ): Promise<{ deploymentName: string; adminKey: string; url: string; deploymentType: DeploymentType; }> { switch (projectSelection.kind) { case "deploymentName": case "teamAndProjectSlugs": return await bigBrainAPI({ ctx, method: "POST", path: "deployment/authorize_within_current_project", data: { selectedDeploymentName: deploymentName, projectSelection: projectSelection, }, }); case "projectDeployKey": // TODO -- this should be supported return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "Project deploy keys are not supported with the --deployment-name flag", }); } } async function fetchDeploymentCredentialsWithinCurrentProject( ctx: Context, projectSelection: ProjectSelection, deploymentSelection: DeploymentSelectionWithinProject, ): Promise<{ deploymentName: string; adminKey: string; url: string; deploymentType: DeploymentType; }> { switch (deploymentSelection.kind) { case "ownDev": { return await handleOwnDev(ctx, projectSelection); } case "implicitProd": case "prod": { return await handleProd(ctx, projectSelection); } case "previewName": return await handlePreview( ctx, deploymentSelection.previewName, projectSelection, ); case "deploymentName": return await handleDeploymentName( ctx, deploymentSelection.deploymentName, projectSelection, ); case "deploymentSelector": return await handleDeploymentSelector( ctx, deploymentSelection.selector, projectSelection, ); default: { deploymentSelection satisfies never; return ctx.crash({ exitCode: 1, errorType: "fatal", // This should be unreachable, so don't bother with a printed message. printedMessage: null, errForSentry: `Unexpected deployment selection: ${deploymentSelection as any}`, }); } } } async function resolveDeploymentNameByReference( ctx: Context, teamSlug: string, projectSlug: string, reference: string, ): Promise<string> { try { const result = await typedPlatformClient(ctx, { throw: true }).GET( "/teams/{team_id_or_slug}/projects/{project_slug}/deployment", { params: { path: { team_id_or_slug: teamSlug, project_slug: projectSlug }, query: { reference }, }, }, ); return result.data!.name; } catch (err) { if ( err instanceof ThrowingFetchError && err.serverErrorData?.code === "DeploymentNotFound" ) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `Deployment “${reference}” not found. To create a new deployment, use ${chalkStderr.bold(`npx convex deployment create ${reference} --team ${teamSlug} --project ${projectSlug} --select`)}`, errForSentry: err, }); } return await logAndHandleFetchError(ctx, err); } } async function handleRefInProject( ctx: Context, selector: InProjectSelector, projectSelection: ProjectSelection, ): Promise<{ deploymentName: string; adminKey: string; url: string; deploymentType: DeploymentType; }> { switch (selector.kind) { case "dev": return await handleOwnDev(ctx, projectSelection); case "prod": return await handleProd(ctx, projectSelection); case "reference": { const access = await checkAccessToSelectedProject(ctx, projectSelection); if (access.kind !== "hasAccess") { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "You don't have access to the selected project. Run `npx convex dev` to select a different project.", }); } const deploymentName = await resolveDeploymentNameByReference( ctx, access.teamSlug, access.projectSlug, selector.reference, ); return await handleDeploymentName(ctx, deploymentName, projectSelection); } } } async function handleDeploymentSelector( ctx: Context, selector: string, projectSelection: ProjectSelection, ): Promise<{ deploymentName: string; adminKey: string; url: string; deploymentType: DeploymentType; }> { const parsed = parseDeploymentSelector(selector); switch (parsed.kind) { case "deploymentName": return await handleDeploymentName( ctx, parsed.deploymentName, projectSelection, ); case "inCurrentProject": return await handleRefInProject(ctx, parsed.selector, projectSelection); case "inProject": { const access = await checkAccessToSelectedProject(ctx, projectSelection); if (access.kind !== "hasAccess") { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "You don't have access to the selected project. Run `npx convex dev` to select a different project.", }); } return await handleRefInProject(ctx, parsed.selector, { kind: "teamAndProjectSlugs", teamSlug: access.teamSlug, projectSlug: parsed.projectSlug, }); } case "inTeamProject": return await handleRefInProject(ctx, parsed.selector, { kind: "teamAndProjectSlugs", teamSlug: parsed.teamSlug, projectSlug: parsed.projectSlug, }); } } async function _loadExistingDeploymentCredentialsForProject( ctx: Context, targetProject: ProjectSelection, deploymentSelection: DeploymentSelectionWithinProject, { ensureLocalRunning } = { ensureLocalRunning: true }, ): Promise<DetailedDeploymentCredentials> { const accessResult = await checkAccessToSelectedProject(ctx, targetProject); if (accessResult.kind === "noAccess") { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "You don't have access to the selected project. Run `npx convex dev` to select a different project.", }); } const result = await fetchDeploymentCredentialsWithinCurrentProject( ctx, targetProject, deploymentSelection, ); logVerbose( `Deployment URL: ${result.url}, Deployment Name: ${result.deploymentName}, Deployment Type: ${result.deploymentType}`, ); if (ensureLocalRunning && result.deploymentType === "local") { await assertLocalBackendRunning(ctx, { url: result.url, deploymentName: result.deploymentName, }); } return { ...result, deploymentFields: { deploymentName: result.deploymentName, deploymentType: result.deploymentType, projectSlug: accessResult.kind === "hasAccess" ? accessResult.projectSlug : null, teamSlug: accessResult.kind === "hasAccess" ? accessResult.teamSlug : null, }, }; } export type DetailedDeploymentCredentials = { adminKey: string; url: string; deploymentFields: { deploymentName: string; deploymentType: DeploymentType; projectSlug: string | null; teamSlug: string | null; } | null; }; /** * This is used by most commands to determine which deployment to act on, taking into account the deployment selection flags. */ export async function loadSelectedDeploymentCredentials( ctx: Context, deploymentSelection: DeploymentSelection, { ensureLocalRunning } = { ensureLocalRunning: true }, ): Promise<DetailedDeploymentCredentials> { switch (deploymentSelection.kind) { case "existingDeployment": // We're already set up. logVerbose( `Deployment URL: ${deploymentSelection.deploymentToActOn.url}, Deployment Name: ${deploymentSelection.deploymentToActOn.deploymentFields?.deploymentName ?? "unknown"}, Deployment Type: ${deploymentSelection.deploymentToActOn.deploymentFields?.deploymentType ?? "unknown"}`, ); return { adminKey: deploymentSelection.deploymentToActOn.adminKey, url: deploymentSelection.deploymentToActOn.url, deploymentFields: deploymentSelection.deploymentToActOn.deploymentFields, }; case "chooseProject": return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "No CONVEX_DEPLOYMENT set, run `npx convex dev` to configure a Convex project", }); case "preview": { const slugs = await getTeamAndProjectFromPreviewAdminKey( ctx, deploymentSelection.previewDeployKey, ); return await _loadExistingDeploymentCredentialsForProject( ctx, { kind: "teamAndProjectSlugs", teamSlug: slugs.teamSlug, projectSlug: slugs.projectSlug, }, // Note that the user could select a non-preview deployment here, and it would succeed if the user is logged in locally because getBigBrainAuth prefers the user's access token over the preview deploy key. deploymentSelection.selectionWithinProject, { ensureLocalRunning }, ); } case "deploymentWithinProject": { return await _loadExistingDeploymentCredentialsForProject( ctx, deploymentSelection.targetProject, deploymentSelection.selectionWithinProject, { ensureLocalRunning }, ); } case "anonymous": { if (deploymentSelection.deploymentName === null) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "No CONVEX_DEPLOYMENT set, run `npx convex dev` to configure a Convex project", }); } const config = await loadAnonymousDeployment( ctx, deploymentSelection.deploymentName, ); const url = localDeploymentUrl(config.ports.cloud); if (ensureLocalRunning) { await assertLocalBackendRunning(ctx, { url, deploymentName: deploymentSelection.deploymentName, }); } return { adminKey: config.adminKey, url, deploymentFields: { deploymentName: deploymentSelection.deploymentName, deploymentType: "anonymous", projectSlug: null, teamSlug: null, }, }; } default: { deploymentSelection satisfies never; return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "Unknown deployment type", }); } } } export async function fetchTeamAndProject( ctx: Context, deploymentName: string, ) { const data = (await bigBrainAPI({ ctx, method: "GET", path: `deployment/${deploymentName}/team_and_project`, })) as { team: string; // slug project: string; // slug teamId: number; projectId: number; }; const { team, project } = data; if (team === undefined || project === undefined) { const msg = "Unknown error when fetching team and project: " + JSON.stringify(data); return await ctx.crash({ exitCode: 1, errorType: "transient", errForSentry: new Error(msg), printedMessage: msg, }); } return data; } export async function fetchTeamAndProjectForKey( ctx: Context, // Deployment deploy key, like `prod:happy-animal-123|<stuff>` deployKey: string, ) { const data = (await bigBrainAPI({ ctx, method: "POST", path: `deployment/team_and_project_for_key`, data: { deployKey: deployKey, }, })) as { team: string; // slug project: string; // slug teamId: number; projectId: number; }; const { team, project } = data; if (team === undefined || project === undefined) { const msg = "Unknown error when fetching team and project: " + JSON.stringify(data); return await ctx.crash({ exitCode: 1, errorType: "transient", errForSentry: new Error(msg), printedMessage: msg, }); } return data; } export async function getTeamsForUser(ctx: Context) { const teams = await bigBrainAPI<{ id: number; name: string; slug: string }[]>( { ctx, method: "GET", path: "teams", }, ); return teams; }