convex
Version:
Client for the Convex Cloud
906 lines (847 loc) • 26.7 kB
text/typescript
import { execSync } from "child_process";
import { Command, Option } from "@commander-js/extra-typings";
import { Context, oneoffContext } from "../bundler/context.js";
import {
logFailure,
logFinishedStep,
logMessage,
showSpinner,
} from "../bundler/log.js";
import {
DeploymentSelection,
getDeploymentSelection,
getProjectDetails,
deploymentNameFromSelection,
} from "./lib/deploymentSelection.js";
import {
logNoDefaultRegionMessage,
selectRegion,
typedBigBrainClient,
typedPlatformClient,
} from "./lib/utils/utils.js";
import { PlatformProjectDetails } from "@convex-dev/platform/managementApi";
import { getTeamAndProjectFromPreviewAdminKey } from "./lib/deployment.js";
import { saveSelectedDeployment } from "./deploymentSelect.js";
import { promptOptions, promptString } from "./lib/utils/prompts.js";
import { chalkStderr } from "chalk";
import { parseDeploymentSelector } from "./lib/deploymentSelector.js";
import {
parseExpiration,
resolveExpiration,
validateExpiration,
} from "./lib/expiration.js";
import { ensureBackendBinaryDownloaded } from "./lib/localDeployment/download.js";
import {
loadProjectLocalConfig,
saveDeploymentConfig,
} from "./lib/localDeployment/filePaths.js";
import {
chooseLocalBackendPorts,
LOCAL_BACKEND_INSTANCE_SECRET,
} from "./lib/localDeployment/utils.js";
import { bigBrainStart } from "./lib/localDeployment/bigBrain.js";
import { importDefaultEnvVars } from "./lib/localDeployment/localDeployment.js";
import { localDeploymentUrl } from "./lib/localDeployment/run.js";
const SUPPORTED_TYPES = ["dev", "prod", "preview"] as const;
export const deploymentCreate = new Command("create")
.summary("Create a new deployment for a project")
.description(
"Create a new deployment for a project.\n\n" +
" Create a dev deployment and select it: `npx convex deployment create dev/my-new-feature --type dev --select`\n" +
" Create a prod deployment named “staging”: `npx convex deployment create staging --type prod`\n" +
" Create a local deployment: `npx convex deployment create local`\n",
)
.argument("[ref]")
.allowExcessArguments(false)
.addOption(
new Option("--type <type>", "Deployment type").choices(SUPPORTED_TYPES),
)
.option("--region <region>", "Deployment region")
.addOption(new Option("--class <class>", "Deployment class").hideHelp())
.option(
"--select",
"Select the new deployment. This will update the Convex environment variables in .env.local. Subsequent `npx convex` commands will run against this deployment.",
)
.option(
"--default",
"Make the new deployment your default production deployment (used by `npx convex deploy`) or your personal dev deployment.",
)
.option(
"--expiration <value>",
'When the deployment expires (e.g. "none", "in 7 days", "2026-04-01T00:00:00Z", or a UNIX timestamp in seconds or milliseconds)',
)
.addOption(new Option("--expiry <value>").hideHelp())
.addOption(new Option("--expires <value>").hideHelp())
.action(async (refParam, options) => {
const expiration = options.expiration ?? options.expiry ?? options.expires;
const ctx = await oneoffContext({
url: undefined,
adminKey: undefined,
envFile: undefined,
});
const currentDeployment = await getDeploymentSelection(ctx, {
url: undefined,
adminKey: undefined,
envFile: undefined,
});
// Handle `deployment create local`
if (refParam !== undefined) {
if (refParam === "local") {
const cloudOnlyFlags = ["type", "region", "class", "default"] as const;
for (const flag of cloudOnlyFlags) {
if (options[flag]) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `--${flag} cannot be used when creating a local deployment`,
});
}
}
if (expiration !== undefined) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `--expiration cannot be used when creating a local deployment`,
});
}
await createLocalDeployment(
ctx,
currentDeployment,
options.select ?? false,
);
return;
}
}
const expiresAt = await resolveExpiresAtOrCrash(ctx, expiration);
const {
ref,
regionDetails,
classDetails,
projectId,
type,
isDefault,
teamSlug,
projectSlug,
} = process.stdin.isTTY
? await resolveOptionsInteractively(
ctx,
currentDeployment,
refParam,
options,
)
: await resolveOptionsNoninteractively(
ctx,
currentDeployment,
refParam,
options,
);
showSpinner(
`Creating ${type} deployment` +
(regionDetails ? ` in region ${regionDetails.displayName}` : "") +
(classDetails ? ` with class ${classDetails.type}` : "") +
"...",
);
const created = (
await typedPlatformClient(ctx).POST(
"/projects/{project_id}/create_deployment",
{
params: {
path: { project_id: projectId },
},
body: {
type,
region: regionDetails?.name ?? null,
reference: ref ?? null,
isDefault,
...(expiresAt !== undefined ? { expiresAt } : {}),
...(classDetails ? { class: classDetails.type } : {}),
},
},
)
).data!;
if (created.kind !== "cloud") {
// This should be impossible
const err = `Expected cloud deployment to be created but got ${created.kind}`;
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: err,
errForSentry: err,
});
}
if (!options.select) {
logFinishedStep(
`Provisioned a ${created.isDefault ? "default " : ""}${created.deploymentType} deployment.`,
);
if (type !== "prod") {
const selectRef = `${teamSlug}:${projectSlug}:${created.reference}`;
logMessage(
`\nTo make \`npx convex\` use this deployment, run ${chalkStderr.bold(`npx convex deployment select ${selectRef}`)}`,
);
logMessage(
chalkStderr.gray(
"Hint: use `--select` to immediately select the newly created deployment.",
),
);
}
}
if (options.select) {
const selection: DeploymentSelection = {
kind: "deploymentWithinProject",
targetProject: {
kind: "teamAndProjectSlugs",
teamSlug,
projectSlug,
},
selectionWithinProject: {
kind: "deploymentSelector",
selector: created.reference,
},
};
await saveSelectedDeployment(
ctx,
created.reference,
selection,
deploymentNameFromSelection(currentDeployment),
);
}
});
export async function createLocalDeployment(
ctx: Context,
currentDeployment: DeploymentSelection,
select: boolean,
): Promise<void> {
const existing = loadProjectLocalConfig(ctx);
if (existing) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: "A local deployment already exists.",
});
}
const { teamSlug, slug: projectSlug } = await resolveProject(
ctx,
currentDeployment,
);
showSpinner("Downloading local backend...");
const { version } = await ensureBackendBinaryDownloaded(ctx, {
kind: "latest",
});
const { cloudPort, sitePort } = await chooseLocalBackendPorts(ctx);
showSpinner("Registering local deployment...");
const { deploymentName, adminKey } = await bigBrainStart(ctx, {
port: cloudPort,
projectSlug,
teamSlug,
instanceName: null,
});
saveDeploymentConfig(ctx, "local", deploymentName, {
backendVersion: version,
ports: { cloud: cloudPort, site: sitePort },
adminKey,
instanceSecret: LOCAL_BACKEND_INSTANCE_SECRET,
});
logFinishedStep("Created local deployment.");
await importDefaultEnvVars(ctx, {
teamSlug,
projectSlug,
deploymentName,
deploymentUrl: localDeploymentUrl(cloudPort),
adminKey,
});
if (select) {
const selection: DeploymentSelection = {
kind: "deploymentWithinProject",
targetProject: {
kind: "deploymentName",
deploymentName,
deploymentType: "local",
},
selectionWithinProject: {
kind: "deploymentSelector",
selector: "local",
},
};
await saveSelectedDeployment(
ctx,
"local",
selection,
deploymentNameFromSelection(currentDeployment),
);
}
const devCommand = "npx convex dev";
if (select) {
logMessage(`\nRun ${chalkStderr.bold(devCommand)} to start it.`);
} else {
logMessage(
`\nTo use this deployment, run:\n` +
chalkStderr.bold(` npx convex deployment select local\n`) +
` Then, run ${chalkStderr.bold(devCommand)} to start it.`,
);
}
}
type RefParam = Parameters<Parameters<typeof deploymentCreate.action>[0]>[0];
type OptionsParam = Parameters<
Parameters<typeof deploymentCreate.action>[0]
>[1];
async function resolveOptionsNoninteractively(
ctx: Context,
currentDeployment: DeploymentSelection,
refParam: RefParam,
options: OptionsParam,
) {
let ref: string | undefined;
let teamAndProject: { teamSlug: string; projectSlug: string } | undefined;
if (refParam) {
const result = parseSelectorForNewDeployment(refParam);
if (result.kind === "invalid") {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: result.message,
});
}
ref = result.ref;
teamAndProject = result.teamAndProject;
}
if (!ref && !options.default) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage:
"Specify a deployment ref or use --default:\n" +
" `npx convex deployment create my-deployment-ref --type dev`\n" +
" `npx convex deployment create --type prod --default`",
});
}
if (!options.type) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `--type is required (supported values: ${SUPPORTED_TYPES.join(", ")})`,
});
}
const project = teamAndProject
? await getProjectDetails(ctx, {
kind: "teamAndProjectSlugs",
teamSlug: teamAndProject.teamSlug,
projectSlug: teamAndProject.projectSlug,
})
: await resolveProject(ctx, currentDeployment);
const projectId = project.id;
// If no region is passed in, the team's default region will be used
let regionDetails: AvailableRegion | null = null;
if (options.region) {
const availableRegions = await fetchAvailableRegions(ctx, project.teamId);
regionDetails = await resolveRegionDetailsOrCrash(
ctx,
availableRegions,
options.region,
);
}
// If no class is passed in, the team's default class will be used
let classDetails: AvailableClass | null = null;
if (options.class) {
const availableClasses = await fetchAvailableClasses(ctx, project.teamId);
classDetails = await resolveClassDetailsOrCrash(
ctx,
availableClasses,
options.class,
);
}
return {
ref,
isDefault: options.default ?? null,
projectId,
regionDetails,
classDetails,
type: options.type,
teamSlug: project.teamSlug,
projectSlug: project.slug,
};
}
async function resolveOptionsInteractively(
ctx: Context,
currentDeployment: DeploymentSelection,
refParam: RefParam,
options: OptionsParam,
) {
let deploymentType: "dev" | "prod" | "preview";
if (options.type) {
deploymentType = logAndUse("type", options.type);
} else {
const dtypeChoices = [
{
name: "dev",
value: "dev" as const,
},
{
name: "preview",
value: "preview" as const,
},
{
name: "prod",
value: "prod" as const,
},
];
deploymentType = await promptOptions(ctx, {
message: "Deployment type?",
choices: dtypeChoices,
});
}
let ref: string | undefined;
let teamAndProject: { teamSlug: string; projectSlug: string } | undefined;
if (refParam) {
const result = parseSelectorForNewDeployment(refParam);
if (result.kind === "invalid") {
logFailure(result.message);
} else {
ref = logAndUse("ref", result.ref);
teamAndProject = result.teamAndProject;
}
}
while (ref === undefined) {
const gitDefault = defaultRef(localGitBranch(), deploymentType);
const input = await promptString(ctx, {
message:
"What do you want to call this deployment?\n" +
chalkStderr.reset.dim(
"The deployment reference will be used to identify your deployment on the dashboard and in CLI commands.\nExamples: staging, dev/james/feature",
) +
"\n>",
...(gitDefault !== undefined ? { default: gitDefault } : {}),
validate: validateTentativeReference,
});
const result = parseSelectorForNewDeployment(input);
if (result.kind === "invalid") {
logFailure(result.message);
continue;
}
ref = result.ref;
teamAndProject = result.teamAndProject;
}
const project = teamAndProject
? await getProjectDetails(ctx, {
kind: "teamAndProjectSlugs",
teamSlug: teamAndProject.teamSlug,
projectSlug: teamAndProject.projectSlug,
})
: await resolveProject(ctx, currentDeployment);
const availableRegions = await fetchAvailableRegions(ctx, project.teamId);
let regionDetails: AvailableRegion;
if (options.region) {
regionDetails = await resolveRegionDetailsOrCrash(
ctx,
availableRegions,
options.region,
);
logAndUse("region", regionDetails.displayName);
} else {
// Use the team's default region if set, or prompt the user to pick
// TODO: this duplicates some of the logic in selectRegionOrUseDefault (npm-packages/convex/src/cli/lib/utils/utils.ts)
const teams = (await typedBigBrainClient(ctx).GET("/teams")).data!;
const team = teams.find((team) => team.slug === project.teamSlug);
if (!team) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Error: Team ${project.teamSlug} not found.`,
});
}
const regionName =
team.defaultRegion ?? (await selectRegion(ctx, team.id, deploymentType));
regionDetails = await resolveRegionDetailsOrCrash(
ctx,
availableRegions,
regionName,
);
if (team.defaultRegion) {
logFinishedStep(
`Using team default region of ${regionDetails.displayName}`,
);
} else {
await logNoDefaultRegionMessage(team.slug);
}
}
let classDetails: AvailableClass | null = null;
if (options.class) {
const availableClasses = await fetchAvailableClasses(ctx, project.teamId);
classDetails = await resolveClassDetailsOrCrash(
ctx,
availableClasses,
options.class,
);
logAndUse("class", classDetails.type);
}
return {
ref,
isDefault: options.default ?? null,
projectId: project.id,
regionDetails,
classDetails,
type: deploymentType,
teamSlug: project.teamSlug,
projectSlug: project.slug,
};
}
type NewDeploymentSelectorResult =
| {
kind: "valid";
ref: string;
teamAndProject?: { teamSlug: string; projectSlug: string };
}
| { kind: "invalid"; message: string };
function parseSelectorForNewDeployment(
selectorString: string,
): NewDeploymentSelectorResult {
const selector = parseDeploymentSelector(selectorString);
switch (selector.kind) {
case "local":
return {
kind: "invalid",
message: `"local" is reserved as an alias for your local deployment. To create one, run ${chalkStderr.bold("npx convex deployment create local")}`,
};
case "deploymentName":
return {
kind: "invalid",
message: `"${selector.deploymentName}" is not a valid deployment reference. References can't look like "word-word-123" — that format is reserved for automatically-generated deployment names.`,
};
case "inCurrentProject": {
const inner = selector.selector;
if (inner.kind === "dev") {
return {
kind: "invalid",
message: `"dev" is reserved as an alias for your default dev deployment.`,
};
}
if (inner.kind === "prod") {
return {
kind: "invalid",
message: `"prod" is reserved as an alias for your default production deployment.`,
};
}
return { kind: "valid", ref: inner.reference };
}
case "inProject": {
return {
kind: "invalid",
message: `Please use "team:project:ref" to specify the team when creating a new deployment in a different project.`,
};
}
case "inTeamProject": {
const inner = selector.selector;
if (inner.kind === "dev") {
return {
kind: "invalid",
message: `"dev" is reserved as an alias for your default dev deployment.`,
};
}
if (inner.kind === "prod") {
return {
kind: "invalid",
message: `"prod" is reserved as an alias for your default production deployment.`,
};
}
return {
kind: "valid",
ref: inner.reference,
teamAndProject: {
teamSlug: selector.teamSlug,
projectSlug: selector.projectSlug,
},
};
}
default:
selector satisfies never;
return {
kind: "invalid",
message: "Unknown state. This is a bug in Convex.",
};
}
}
async function resolveProject(
ctx: Context,
deploymentSelection: DeploymentSelection,
): Promise<PlatformProjectDetails> {
switch (deploymentSelection.kind) {
case "existingDeployment": {
const { deploymentFields } = deploymentSelection.deploymentToActOn;
if (deploymentFields) {
return await getProjectDetails(ctx, {
kind: "deploymentName",
deploymentName: deploymentFields.deploymentName,
deploymentType: null,
});
}
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage:
"Cannot infer project from the current deployment configuration. Use `team:project:ref` to specify team and project slugs.",
});
}
case "deploymentWithinProject": {
return await getProjectDetails(ctx, deploymentSelection.targetProject);
}
case "preview": {
const slugs = await getTeamAndProjectFromPreviewAdminKey(
ctx,
deploymentSelection.previewDeployKey,
);
return await getProjectDetails(ctx, {
kind: "teamAndProjectSlugs",
teamSlug: slugs.teamSlug,
projectSlug: slugs.projectSlug,
});
}
case "chooseProject":
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage:
"No project configured yet. Use `team:project:ref` to specify team and project slugs.",
});
case "anonymous":
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage:
"Cannot create a deployment in anonymous mode. " +
"Run `npx convex login` and configure a project first.",
});
default: {
deploymentSelection satisfies never;
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Unexpected deployment selection kind.`,
});
}
}
}
const REGION_NAME_TO_ALIAS: Record<string, string> = {
"aws-us-east-1": "us",
"aws-eu-west-1": "eu",
};
const REGION_ALIAS_TO_NAME = Object.fromEntries(
Object.entries(REGION_NAME_TO_ALIAS).map(([name, alias]) => [alias, name]),
);
export async function fetchAvailableRegions(ctx: Context, teamId: number) {
const regionsResponse = (
await typedPlatformClient(ctx).GET(
"/teams/{team_id}/list_deployment_regions",
{
params: {
path: { team_id: `${teamId}` },
},
},
)
).data!;
return regionsResponse.items.filter((item) => item.available);
}
type AvailableRegion = Awaited<
ReturnType<typeof fetchAvailableRegions>
>[number];
export function resolveRegionDetails(
availableRegions: AvailableRegion[],
region: string,
) {
const resolvedRegion = REGION_ALIAS_TO_NAME[region] ?? region;
return availableRegions.find((item) => item.name === resolvedRegion) ?? null;
}
async function resolveRegionDetailsOrCrash(
ctx: Context,
availableRegions: AvailableRegion[],
region: string,
) {
const regionDetails = resolveRegionDetails(availableRegions, region);
if (!regionDetails) {
return await crashInvalidRegion(ctx, availableRegions, region);
}
return regionDetails;
}
function invalidRegionMessage(
availableRegions: AvailableRegion[],
region: string,
): string {
const formatted = availableRegions
.map(
(item) =>
` Use \`--region ${REGION_NAME_TO_ALIAS[item.name] ?? item.name}\` for ${item.displayName}`,
)
.join("\n");
return `Invalid region "${region}".\n\n` + formatted;
}
async function crashInvalidRegion(
ctx: Context,
availableRegions: AvailableRegion[],
region: string,
): Promise<never> {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: invalidRegionMessage(availableRegions, region),
});
}
export async function fetchAvailableClasses(ctx: Context, teamId: number) {
const classesResponse = (
await typedPlatformClient(ctx).GET(
"/teams/{team_id}/list_deployment_classes",
{
params: {
path: { team_id: `${teamId}` },
},
},
)
).data!;
return classesResponse.items.filter((item) => item.available);
}
type AvailableClass = Awaited<ReturnType<typeof fetchAvailableClasses>>[number];
export function resolveClassDetails(
availableClasses: AvailableClass[],
className: string,
) {
return availableClasses.find((item) => item.type === className) ?? null;
}
async function resolveClassDetailsOrCrash(
ctx: Context,
availableClasses: AvailableClass[],
className: string,
) {
const classDetails = resolveClassDetails(availableClasses, className);
if (!classDetails) {
return await crashInvalidClass(ctx, availableClasses, className);
}
return classDetails;
}
function invalidClassMessage(
availableClasses: AvailableClass[],
className: string,
): string {
const formatted = availableClasses
.map((item) => ` \`--class ${item.type}\``)
.join("\n");
return `Invalid class "${className}".\n\nAvailable classes:\n` + formatted;
}
async function crashInvalidClass(
ctx: Context,
availableClasses: AvailableClass[],
className: string,
): Promise<never> {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: invalidClassMessage(availableClasses, className),
});
}
async function resolveExpiresAtOrCrash(
ctx: Context,
expiration: string | undefined,
): Promise<number | null | undefined> {
if (!expiration) {
return undefined;
}
const parsed = parseExpiration(expiration);
if (parsed.kind === "error") {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: parsed.message,
});
}
const now = Date.now();
const resolved = resolveExpiration(parsed, now);
if (resolved !== null) {
const validation = validateExpiration(resolved, now);
if (validation.kind === "error") {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: validation.message,
});
}
}
return resolved;
}
/**
* Helper to log a value passed in as a CLI argument in the interactive flow.
*/
function logAndUse<T extends string | boolean>(label: string, value: T): T {
logFinishedStep(`Using ${label}: ${chalkStderr.bold(value)}`);
return value;
}
// This is an oversimplification, it’s fine if it fails later
function validateTentativeReference(tentativeReference: string): true | string {
if (tentativeReference.length < 3) {
return "References must be at least 3 characters";
}
if (tentativeReference.length > 100) {
return "References must be at most 100 characters";
}
if (!/^[a-z0-9/-]+$/.test(tentativeReference)) {
return "References can only contain lowercase letters, numbers, `-`, and `/`";
}
if (tentativeReference === "dev") {
return '"dev" is reserved as an alias for your default dev deployment.';
}
if (tentativeReference === "prod") {
return '"prod" is reserved as an alias for your default production deployment.';
}
if (tentativeReference === "local") {
return `"local" is reserved as an alias for your local deployment. To create one, run ${chalkStderr.bold("npx convex deployment create local")}`;
}
if (/^[a-z]+-[a-z]+-\d+$/.test(tentativeReference)) {
return 'References can\'t look like "word-word-123" — that format is reserved for automatically-generated deployment names. Try something like dev/my-feature or staging instead.';
}
return true;
}
/**
* Get the current local git branch name by shelling out to git.
* Returns null if git is unavailable, the repo is in detached HEAD state,
* or the branch is main/master.
*/
function localGitBranch(): string | null {
try {
const branch = (
execSync("git rev-parse --abbrev-ref HEAD", {
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
}) as Buffer
)
.toString()
.trim();
if (
!branch ||
branch === "HEAD" ||
branch === "main" ||
branch === "master"
) {
return null;
}
return branch;
} catch {
return null;
}
}
/**
* Slugify a git branch name into a valid deployment reference.
* Returns undefined if the result would fail validation.
*/
function defaultRef(
branch: string | null,
deploymentType: "dev" | "prod" | "preview",
): string | undefined {
if (deploymentType !== "dev" && deploymentType !== "preview") {
return undefined;
}
if (!branch) return undefined;
const slug = branch
.replace(/[^a-z0-9/-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
if (!slug) return undefined;
const ref = `${deploymentType}/${slug}`;
const valid = validateTentativeReference(ref);
if (valid !== true) return undefined;
return ref;
}