convex
Version:
Client for the Convex Cloud
491 lines (465 loc) • 13.3 kB
text/typescript
import chalk from "chalk";
import {
Context,
logFailure,
logFinishedStep,
logMessage,
logWarning,
showSpinner,
} from "../bundler/context.js";
import {
DeploymentType,
DeploymentName,
fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows,
createProject,
} from "./lib/api.js";
import {
configFilepath,
configName,
readProjectConfig,
upgradeOldAuthInfoToAuthConfig,
writeProjectConfig,
} from "./lib/config.js";
import {
CONVEX_DEPLOYMENT_VAR_NAME,
DeploymentDetails,
eraseDeploymentEnvVar,
writeDeploymentEnvVar,
} from "./lib/deployment.js";
import { finalizeConfiguration } from "./lib/init.js";
import {
bigBrainAPIMaybeThrows,
functionsDir,
getConfiguredDeploymentName,
hasProjects,
logAndHandleFetchError,
ThrowingFetchError,
validateOrSelectProject,
validateOrSelectTeam,
} from "./lib/utils/utils.js";
import { writeConvexUrlToEnvFile } from "./lib/envvars.js";
import path from "path";
import { projectDashboardUrl } from "./dashboard.js";
import { doCodegen, doInitCodegen } from "./lib/codegen.js";
import { handleLocalDeployment } from "./lib/localDeployment/localDeployment.js";
import { promptOptions, promptString } from "./lib/utils/prompts.js";
type DeploymentCredentials = {
url: string;
adminKey: string;
cleanupHandle: (() => Promise<void>) | null;
};
/**
* As of writing, this is used by:
* - `npx convex dev`
* - `npx convex codegen`
*
* But is not used by `npx convex deploy` or other commands.
*/
export async function deploymentCredentialsOrConfigure(
ctx: Context,
chosenConfiguration: "new" | "existing" | "ask" | null,
cmdOptions: {
prod: boolean;
local: boolean;
localOptions: {
ports?: {
cloud: number;
site: number;
};
backendVersion?: string | undefined;
forceUpgrade: boolean;
};
team?: string | undefined;
project?: string | undefined;
url?: string | undefined;
adminKey?: string | undefined;
},
): Promise<
DeploymentCredentials & {
deploymentName?: DeploymentName;
cleanupHandle: null | (() => Promise<void>);
}
> {
if (cmdOptions.url !== undefined && cmdOptions.adminKey !== undefined) {
const credentials = await handleManuallySetUrlAndAdminKey(ctx, {
url: cmdOptions.url,
adminKey: cmdOptions.adminKey,
});
return { ...credentials, cleanupHandle: null };
}
const { projectSlug, teamSlug } = await selectProject(
ctx,
chosenConfiguration,
{ team: cmdOptions.team, project: cmdOptions.project },
);
const deploymentOptions: DeploymentOptions = cmdOptions.prod
? { kind: "prod" }
: cmdOptions.local
? { kind: "local", ...cmdOptions.localOptions }
: { kind: "dev" };
const {
deploymentName,
deploymentUrl: url,
adminKey,
cleanupHandle,
} = await ensureDeploymentProvisioned(ctx, {
teamSlug,
projectSlug,
deploymentOptions,
});
await updateEnvAndConfigForDeploymentSelection(ctx, {
url,
deploymentName,
teamSlug,
projectSlug,
deploymentType: deploymentOptions.kind,
});
return { deploymentName, url, adminKey, cleanupHandle };
}
async function handleManuallySetUrlAndAdminKey(
ctx: Context,
cmdOptions: { url: string; adminKey: string },
) {
const { url, adminKey } = cmdOptions;
const didErase = await eraseDeploymentEnvVar(ctx);
if (didErase) {
logMessage(
ctx,
chalk.yellowBright(
`Removed the CONVEX_DEPLOYMENT environment variable from .env.local`,
),
);
}
const envVarWrite = await writeConvexUrlToEnvFile(ctx, url);
if (envVarWrite !== null) {
logMessage(
ctx,
chalk.green(
`Saved the given --url as ${envVarWrite.envVar} to ${envVarWrite.envFile}`,
),
);
}
return { url, adminKey };
}
async function selectProject(
ctx: Context,
chosenConfiguration: "new" | "existing" | "ask" | null,
cmdOptions: {
team?: string | undefined;
project?: string | undefined;
},
): Promise<{ teamSlug: string; projectSlug: string }> {
let result:
| { teamSlug: string; projectSlug: string }
| "AccessDenied"
| null = null;
if (chosenConfiguration === null) {
result = await getConfiguredProjectSlugs(ctx);
if (result !== null && result !== "AccessDenied") {
return result;
}
}
const reconfigure = result === "AccessDenied";
// Prompt the user to select a project.
const choice =
chosenConfiguration !== "ask" && chosenConfiguration !== null
? chosenConfiguration
: await askToConfigure(ctx, reconfigure);
switch (choice) {
case "new":
return selectNewProject(ctx, cmdOptions);
case "existing":
return selectExistingProject(ctx, cmdOptions);
default:
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: "No project selected.",
});
}
}
async function getConfiguredProjectSlugs(ctx: Context): Promise<
| {
projectSlug: string;
teamSlug: string;
}
| "AccessDenied"
| null
> {
// Try and infer the project from the deployment name
const deploymentName = await getConfiguredDeploymentName(ctx);
if (deploymentName !== null) {
const result = await getTeamAndProjectSlugForDeployment(ctx, {
deploymentName,
kind: "cloud",
});
if (result !== null) {
return result;
} else {
logFailure(
ctx,
`You don't have access to the project with deployment ${chalk.bold(
deploymentName,
)}, as configured in ${chalk.bold(CONVEX_DEPLOYMENT_VAR_NAME)}`,
);
return "AccessDenied";
}
}
// Try and infer the project from `convex.json`
const { projectConfig } = await readProjectConfig(ctx);
const { team, project } = projectConfig;
if (typeof team === "string" && typeof project === "string") {
const hasAccess = await hasAccessToProject(ctx, {
teamSlug: team,
projectSlug: project,
});
if (!hasAccess) {
logFailure(
ctx,
`You don't have access to the project ${chalk.bold(project)} in team ${chalk.bold(team)} as configured in ${chalk.bold("convex.json")}`,
);
return "AccessDenied";
}
return { teamSlug: team, projectSlug: project };
}
return null;
}
async function getTeamAndProjectSlugForDeployment(
ctx: Context,
selector: { deploymentName: string; kind: "local" | "cloud" },
): Promise<{ teamSlug: string; projectSlug: string } | null> {
try {
const body = await bigBrainAPIMaybeThrows({
ctx,
url: `/api/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);
}
}
async function hasAccessToProject(
ctx: Context,
selector: { projectSlug: string; teamSlug: string },
): Promise<boolean> {
try {
await bigBrainAPIMaybeThrows({
ctx,
url: `/api/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);
}
}
const cwd = path.basename(process.cwd());
async function selectNewProject(
ctx: Context,
config: {
team?: string | undefined;
project?: string | undefined;
},
) {
const { teamSlug: selectedTeam, chosen: didChooseBetweenTeams } =
await validateOrSelectTeam(ctx, config.team, "Team:");
let projectName: string = config.project || cwd;
if (!config.project) {
projectName = await promptString(ctx, {
message: "Project name:",
default: cwd,
});
}
showSpinner(ctx, "Creating new Convex project...");
let projectSlug, teamSlug, projectsRemaining;
try {
({ projectSlug, teamSlug, projectsRemaining } = await createProject(ctx, {
teamSlug: selectedTeam,
projectName,
}));
} catch (err) {
logFailure(ctx, "Unable to create project.");
return await logAndHandleFetchError(ctx, err);
}
const teamMessage = didChooseBetweenTeams
? " in team " + chalk.bold(teamSlug)
: "";
logFinishedStep(
ctx,
`Created project ${chalk.bold(
projectSlug,
)}${teamMessage}, manage it at ${chalk.bold(
projectDashboardUrl(teamSlug, projectSlug),
)}`,
);
if (projectsRemaining <= 2) {
logWarning(
ctx,
chalk.yellow.bold(
`Your account now has ${projectsRemaining} project${
projectsRemaining === 1 ? "" : "s"
} remaining.`,
),
);
}
const { projectConfig: existingProjectConfig } = await readProjectConfig(ctx);
const configPath = await configFilepath(ctx);
const functionsPath = functionsDir(configPath, existingProjectConfig);
await doInitCodegen(ctx, functionsPath, true);
// Disable typechecking since there isn't any code yet.
await doCodegen(ctx, functionsPath, "disable");
return { teamSlug, projectSlug };
}
async function selectExistingProject(
ctx: Context,
config: {
team?: string | undefined;
project?: string | undefined;
},
): Promise<{ teamSlug: string; projectSlug: string }> {
const { teamSlug } = await validateOrSelectTeam(ctx, config.team, "Team:");
const projectSlug = await validateOrSelectProject(
ctx,
config.project,
teamSlug,
"Configure project",
"Project:",
);
if (projectSlug === null) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: "Run the command again to create a new project instead.",
});
}
showSpinner(ctx, `Reinitializing project ${projectSlug}...\n`);
const { projectConfig: existingProjectConfig } = await readProjectConfig(ctx);
const functionsPath = functionsDir(configName(), existingProjectConfig);
await doCodegen(ctx, functionsPath, "disable");
return { teamSlug, projectSlug };
}
async function askToConfigure(
ctx: Context,
reconfigure: boolean,
): Promise<"new" | "existing"> {
if (!(await hasProjects(ctx))) {
return "new";
}
return await promptOptions(ctx, {
message: reconfigure
? "Configure a different project?"
: "What would you like to configure?",
default: "new",
choices: [
{ name: "create a new project", value: "new" },
{ name: "choose an existing project", value: "existing" },
],
});
}
type DeploymentOptions =
| {
kind: "prod";
}
| { kind: "dev" }
| {
kind: "local";
ports?: {
cloud: number;
site: number;
};
backendVersion?: string;
forceUpgrade: boolean;
};
/**
* This method assumes that the member has access to the selected project.
*/
async function ensureDeploymentProvisioned(
ctx: Context,
options: {
teamSlug: string;
projectSlug: string;
deploymentOptions: DeploymentOptions;
},
): Promise<DeploymentDetails> {
switch (options.deploymentOptions.kind) {
case "dev":
case "prod": {
const credentials =
await fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows(
ctx,
{ teamSlug: options.teamSlug, projectSlug: options.projectSlug },
options.deploymentOptions.kind,
);
return {
...credentials,
cleanupHandle: null,
onActivity: null,
};
}
case "local": {
const credentials = await handleLocalDeployment(ctx, {
teamSlug: options.teamSlug,
projectSlug: options.projectSlug,
...options.deploymentOptions,
});
return credentials;
}
default:
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Invalid deployment type: ${(options.deploymentOptions as any).kind}`,
errForSentry: `Invalid deployment type: ${(options.deploymentOptions as any).kind}`,
});
}
}
async function updateEnvAndConfigForDeploymentSelection(
ctx: Context,
options: {
url: string;
deploymentName: string;
teamSlug: string;
projectSlug: string;
deploymentType: DeploymentType;
},
) {
const { configPath, projectConfig: existingProjectConfig } =
await readProjectConfig(ctx);
const functionsPath = functionsDir(configName(), existingProjectConfig);
const { wroteToGitIgnore, changedDeploymentEnvVar } =
await writeDeploymentEnvVar(ctx, options.deploymentType, {
team: options.teamSlug,
project: options.projectSlug,
deploymentName: options.deploymentName,
});
const projectConfig = await upgradeOldAuthInfoToAuthConfig(
ctx,
existingProjectConfig,
functionsPath,
);
await writeProjectConfig(ctx, projectConfig, {
deleteIfAllDefault: true,
});
await finalizeConfiguration(ctx, {
deploymentType: options.deploymentType,
url: options.url,
wroteToGitIgnore,
changedDeploymentEnvVar,
functionsPath: functionsDir(configPath, projectConfig),
});
}