From 66daf335bd1577eb7318fa51f3e8be19b9e90f0b Mon Sep 17 00:00:00 2001 From: Sarah Shader Date: Mon, 29 Jul 2024 11:32:42 -0400 Subject: [PATCH] Refactor CLI in preparation for local dev (#27783) I eventually want `npx convex dev` to spin up a backend locally + save some metadata associating it with a project in big brain. So I roughly want the structure of `npx convex dev` to involve choosing a project + ensuring you have access to it, and then spinning up a deployment based on the flags you passed (e.g. `--prod` -> prod deployment). I believe I've preserved all the flows here, but would appreciate some brainstorming of flows to check. GitOrigin-RevId: 13eb1f580dffb53cb054462a66b0d2e1cec30f6e --- npm-packages/convex/src/cli/configure.ts | 640 +++++++++++------- npm-packages/convex/src/cli/dev.ts | 10 + npm-packages/convex/src/cli/lib/api.ts | 48 +- npm-packages/convex/src/cli/lib/components.ts | 1 + npm-packages/convex/src/cli/lib/deployment.ts | 22 +- npm-packages/convex/src/cli/lib/init.ts | 174 +---- npm-packages/convex/src/cli/lib/reinit.ts | 88 --- 7 files changed, 481 insertions(+), 502 deletions(-) delete mode 100644 npm-packages/convex/src/cli/lib/reinit.ts diff --git a/npm-packages/convex/src/cli/configure.ts b/npm-packages/convex/src/cli/configure.ts index 27374cdc..4e9230d0 100644 --- a/npm-packages/convex/src/cli/configure.ts +++ b/npm-packages/convex/src/cli/configure.ts @@ -2,46 +2,111 @@ import chalk from "chalk"; import inquirer from "inquirer"; import { Context, - logError, logFailure, + logFinishedStep, logMessage, + logWarning, + showSpinner, } from "../bundler/context.js"; import { DeploymentType, DeploymentName, - fetchDeploymentCredentialsForName, fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows, + createProject, } from "./lib/api.js"; import { - ProjectConfig, - enforceDeprecatedConfigField, + configFilepath, + configName, readProjectConfig, upgradeOldAuthInfoToAuthConfig, writeProjectConfig, } from "./lib/config.js"; import { + CONVEX_DEPLOYMENT_VAR_NAME, eraseDeploymentEnvVar, writeDeploymentEnvVar, } from "./lib/deployment.js"; -import { init } from "./lib/init.js"; -import { reinit } from "./lib/reinit.js"; +import { finalizeConfiguration } from "./lib/init.js"; import { - ErrorData, + bigBrainAPIMaybeThrows, functionsDir, getConfiguredDeploymentName, - hasProject, hasProjects, - hasTeam, logAndHandleFetchError, ThrowingFetchError, + validateOrSelectProject, + validateOrSelectTeam, } from "./lib/utils.js"; import { writeConvexUrlToEnvFile } from "./lib/envvars.js"; +import path from "path"; +import { projectDashboardUrl } from "./dashboard.js"; +import { doCodegen, doInitCodegen } from "./lib/codegen.js"; type DeploymentCredentials = { url: string; adminKey: string; + cleanupHandle: (() => Promise) | null; }; +/** + * As of writing, this is used by: + * - `npx convex dev` + * - `npx convex init` (deprecated) + * - `npx convex reinit` (deprecated) + * + * 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; + team?: string | undefined; + project?: string | undefined; + url?: string | undefined; + adminKey?: string | undefined; + }, +): Promise< + DeploymentCredentials & { + deploymentName?: DeploymentName; + cleanupHandle: null | (() => Promise); + } +> { + 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 deploymentType = cmdOptions.prod + ? "prod" + : cmdOptions.local + ? "local" + : "dev"; + const { deploymentName, url, adminKey, cleanupHandle } = + await ensureDeploymentProvisioned(ctx, { + teamSlug, + projectSlug, + deploymentType, + }); + await updateEnvAndConfigForDeploymentSelection(ctx, { + url, + deploymentName, + teamSlug, + projectSlug, + deploymentType, + }); + + return { deploymentName, url, adminKey, cleanupHandle }; +} + // This works like running `dev --once` for the first time // but without a push. // It only exists for backwards compatibility with existing @@ -58,6 +123,7 @@ export async function initOrReinitForDeprecatedCommands( const { url } = await deploymentCredentialsOrConfigure(ctx, null, { ...cmdOptions, prod: false, + local: false, }); // Try the CONVEX_URL write again in case the user had an existing // convex.json but didn't have CONVEX_URL in .env.local. @@ -72,288 +138,271 @@ export async function initOrReinitForDeprecatedCommands( } } -export async function deploymentCredentialsOrConfigure( +async function handleManuallySetUrlAndAdminKey( ctx: Context, - chosenConfiguration: "new" | "existing" | "ask" | null, - cmdOptions: { - prod: boolean; - team?: string | undefined; - project?: string | undefined; - url?: string | undefined; - adminKey?: string | undefined; - }, -): Promise { + cmdOptions: { url: string; adminKey: string }, +) { const { url, adminKey } = cmdOptions; - if (url !== undefined && adminKey !== undefined) { - 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 }; - } - const deploymentType = cmdOptions.prod ? "prod" : "dev"; - const configuredDeployment = - chosenConfiguration === null - ? await getConfiguredDeploymentOrUpgrade(ctx, deploymentType) - : null; - // No configured deployment NOR existing config - if (configuredDeployment === null) { - const choice = - chosenConfiguration !== "ask" && chosenConfiguration !== null - ? chosenConfiguration - : await askToConfigure(ctx); - return await initOrReinit(ctx, choice, deploymentType, cmdOptions); - } - // Existing config but user doesn't have access to it - if ("error" in configuredDeployment) { - const projectConfig = (await readProjectConfig(ctx)).projectConfig; - const choice = await askToReconfigure( + const didErase = await eraseDeploymentEnvVar(ctx); + if (didErase) { + logMessage( ctx, - projectConfig, - configuredDeployment.error, + chalk.yellowBright( + `Removed the CONVEX_DEPLOYMENT environment variable from .env.local`, + ), ); - return initOrReinit(ctx, choice, deploymentType, cmdOptions); } - const { deploymentName } = configuredDeployment; - const adminKeyAndUrlForConfiguredDeployment = - await fetchDeploymentCredentialsForName( + const envVarWrite = await writeConvexUrlToEnvFile(ctx, url); + if (envVarWrite !== null) { + logMessage( ctx, - deploymentName, - deploymentType, + chalk.green( + `Saved the given --url as ${envVarWrite.envVar} to ${envVarWrite.envFile}`, + ), ); - // Configured deployment and user has access - if (!("error" in adminKeyAndUrlForConfiguredDeployment)) { - return adminKeyAndUrlForConfiguredDeployment; } - await checkForDeploymentTypeError( - ctx, - adminKeyAndUrlForConfiguredDeployment.error, - deploymentType, - ); - - // Configured deployment and user doesn't has access to it - const choice = await askToReconfigureNew(ctx, deploymentName); - return initOrReinit(ctx, choice, deploymentType, cmdOptions); + return { url, adminKey }; } -async function checkForDeploymentTypeError( +async function selectProject( ctx: Context, - error: unknown, - deploymentType: DeploymentType, -) { - let data: ErrorData | null = null; - if (error instanceof ThrowingFetchError) { - data = error.serverErrorData || null; + 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(1, "fatal", "No project selected."); } - if (data && "code" in data && data.code === "DeploymentTypeMismatch") { - if (deploymentType === "prod") { +} + +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, - "Use `npx convex deploy` to push changes to your production deployment", + `You don't have access to the project with deployment ${chalk.bold( + deploymentName, + )}, as configured in ${chalk.bold(CONVEX_DEPLOYMENT_VAR_NAME)}`, ); - } else { + 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, - "CONVEX_DEPLOYMENT is a production deployment, but --prod flag was not specified. " + - "Use `npx convex dev --prod` to develop against this production deployment.", + `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"; } - logError(ctx, chalk.red(data.message)); - await ctx.crash(1, "invalid filesystem data", error); + return { teamSlug: team, projectSlug: project }; } + return null; } -async function getConfiguredDeploymentOrUpgrade( +async function getTeamAndProjectSlugForDeployment( ctx: Context, - deploymentType: DeploymentType, -) { - const deploymentName = await getConfiguredDeploymentName(ctx); - if (deploymentName !== null) { - return { deploymentName }; + 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); } - return await upgradeOldConfigToDeploymentVar(ctx, deploymentType); } -async function initOrReinit( +async function hasAccessToProject( ctx: Context, - choice: "new" | "existing", - deploymentType: DeploymentType, - cmdOptions: { team?: string | undefined; project?: string | undefined }, -): Promise { - switch (choice) { - case "new": - return (await init(ctx, deploymentType, cmdOptions))!; - case "existing": { - return (await reinit(ctx, deploymentType, cmdOptions))!; - } - default: { - return choice; + selector: { projectSlug: string; teamSlug: string }, +): Promise { + 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); } } -async function upgradeOldConfigToDeploymentVar( +const cwd = path.basename(process.cwd()); +async function selectNewProject( ctx: Context, - deploymentType: DeploymentType, -): Promise<{ deploymentName: string } | { error: unknown } | null> { - const { configPath, projectConfig } = await readProjectConfig(ctx); - const { team, project } = projectConfig; - if (typeof team !== "string" || typeof project !== "string") { - // The config is not a valid old config, proceed to init/reinit - return null; + 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 (process.stdin.isTTY && !config.project) { + projectName = ( + await inquirer.prompt([ + { + type: "input", + name: "project", + message: "Project name:", + default: cwd, + }, + ]) + ).project; } - let devDeploymentName; + + showSpinner(ctx, "Creating new Convex project..."); + + let projectSlug, teamSlug, projectsRemaining; try { - const { deploymentName } = - await fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows( - ctx, - { teamSlug: team, projectSlug: project }, - deploymentType, - ); - devDeploymentName = deploymentName!; - } catch (error) { - // Could not retrieve the deployment name using the old config, proceed to reconfigure - return { error }; + ({ projectSlug, teamSlug, projectsRemaining } = await createProject(ctx, { + teamSlug: selectedTeam, + projectName, + })); + } catch (err) { + logFailure(ctx, "Unable to create project."); + return await logAndHandleFetchError(ctx, err); } - await writeDeploymentEnvVar(ctx, deploymentType, { - team, - project, - deploymentName: devDeploymentName, - }); - logMessage( + const teamMessage = didChooseBetweenTeams + ? " in team " + chalk.bold(teamSlug) + : ""; + logFinishedStep( ctx, - chalk.green( - `Saved the ${deploymentType} deployment name as CONVEX_DEPLOYMENT to .env.local`, - ), + `Created project ${chalk.bold( + projectSlug, + )}${teamMessage}, manage it at ${chalk.bold( + projectDashboardUrl(teamSlug, projectSlug), + )}`, ); - const projectConfigWithoutAuthInfo = await upgradeOldAuthInfoToAuthConfig( - ctx, - projectConfig, - functionsDir(configPath, projectConfig), - ); - await writeProjectConfig(ctx, projectConfigWithoutAuthInfo, { - deleteIfAllDefault: true, - }); - return { deploymentName: devDeploymentName }; -} -async function askToConfigure(ctx: Context): Promise<"new" | "existing"> { - if (!(await hasProjects(ctx))) { - return "new"; + if (projectsRemaining <= 2) { + logWarning( + ctx, + chalk.yellow.bold( + `Your account now has ${projectsRemaining} project${ + projectsRemaining === 1 ? "" : "s" + } remaining.`, + ), + ); } - return await promptToInitWithProjects(); + + 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 askToReconfigure( +async function selectExistingProject( ctx: Context, - projectConfig: ProjectConfig, - error: unknown, -): Promise<"new" | "existing"> { - const team = await enforceDeprecatedConfigField(ctx, projectConfig, "team"); - const project = await enforceDeprecatedConfigField( + 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, - projectConfig, - "project", + config.project, + teamSlug, + "Configure project", + "Project:", ); - const [isExistingTeam, existingProject, hasAnyProjects] = await Promise.all([ - await hasTeam(ctx, team), - await hasProject(ctx, team, project), - await hasProjects(ctx), - ]); - - // The config is good so there must be something else going on, - // fatal with the original error - if (isExistingTeam && existingProject) { - return await logAndHandleFetchError(ctx, error); + if (projectSlug === null) { + logFailure(ctx, "Run the command again to create a new project instead."); + return await ctx.crash(1); } - if (isExistingTeam) { - logFailure( - ctx, - `Project ${chalk.bold(project)} does not exist in your team ${chalk.bold( - team, - )}, as configured in ${chalk.bold("convex.json")}`, - ); - } else { - logFailure( - ctx, - `You don't have access to team ${chalk.bold( - team, - )}, as configured in ${chalk.bold("convex.json")}`, - ); - } - if (!hasAnyProjects) { - const { confirmed } = await inquirer.prompt([ - { - type: "confirm", - name: "confirmed", - message: `Create a new project?`, - default: true, - }, - ]); - if (!confirmed) { - logFailure( - ctx, - "Run `npx convex dev` in a directory with a valid convex.json.", - ); - return await ctx.crash(1, "invalid filesystem data"); - } - return "new"; - } + showSpinner(ctx, `Reinitializing project ${projectSlug}...\n`); + + const { projectConfig: existingProjectConfig } = await readProjectConfig(ctx); + + const functionsPath = functionsDir(configName(), existingProjectConfig); - return await promptToReconfigure(); + await doCodegen(ctx, functionsPath, "disable"); + return { teamSlug, projectSlug }; } -async function askToReconfigureNew( +async function askToConfigure( ctx: Context, - configuredDeploymentName: DeploymentName, + reconfigure: boolean, ): Promise<"new" | "existing"> { - logFailure( - ctx, - `You don't have access to the project with deployment ${chalk.bold( - configuredDeploymentName, - )}, as configured in ${chalk.bold("CONVEX_DEPLOYMENT")}`, - ); - - const hasAnyProjects = await hasProjects(ctx); - - if (!hasAnyProjects) { - const { confirmed } = await inquirer.prompt([ - { - type: "confirm", - name: "confirmed", - message: `Configure a new project?`, - default: true, - }, - ]); - if (!confirmed) { - logFailure( - ctx, - "Run `npx convex dev` in a directory with a valid CONVEX_DEPLOYMENT set", - ); - return await ctx.crash(1, "invalid filesystem data"); - } + if (!(await hasProjects(ctx))) { return "new"; } - - return await promptToReconfigure(); + return await promptToInitWithProjects(reconfigure); } -export async function promptToInitWithProjects(): Promise<"new" | "existing"> { +async function promptToInitWithProjects( + reconfigure: boolean, +): Promise<"new" | "existing"> { const { choice } = await inquirer.prompt([ { // In the Convex mono-repo, `list` seems to cause the command to not @@ -362,33 +411,110 @@ export async function promptToInitWithProjects(): Promise<"new" | "existing"> { ? "search-list" : "list", name: "choice", - message: `What would you like to configure?`, + message: reconfigure + ? "Configure a different project?" + : "What would you like to configure?", default: "new", choices: [ - { name: "a new project", value: "new" }, - { name: "an existing project", value: "existing" }, + { name: "create a new project", value: "new" }, + { name: "choose an existing project", value: "existing" }, ], }, ]); return choice; } -export async function promptToReconfigure(): Promise<"new" | "existing"> { - const { choice } = await inquirer.prompt([ - { - // In the Convex mono-repo, `list` seems to cause the command to not - // respond to CTRL+C while `search-list` does not. - type: process.env.CONVEX_RUNNING_LIVE_IN_MONOREPO - ? "search-list" - : "list", - name: "choice", - message: `Configure a different project?`, - default: "new", - choices: [ - { name: "create new project", value: "new" }, - { name: "choose an existing project", value: "existing" }, - ], - }, - ]); - return choice; +/** + * This method assumes that the member has access to the selected project. + */ +async function ensureDeploymentProvisioned( + ctx: Context, + options: { + teamSlug: string; + projectSlug: string; + deploymentType: DeploymentType; + }, +): Promise<{ + deploymentName: string; + url: string; + adminKey: string; + cleanupHandle: null | (() => Promise); +}> { + switch (options.deploymentType) { + case "dev": + case "prod": { + const credentials = + await fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows( + ctx, + { teamSlug: options.teamSlug, projectSlug: options.projectSlug }, + options.deploymentType, + ); + return { + ...credentials, + cleanupHandle: null, + }; + } + case "local": { + const credentials = await handleLocalDev(ctx, { + teamSlug: options.teamSlug, + projectSlug: options.projectSlug, + }); + return credentials; + } + default: + return await ctx.crash( + 1, + "fatal", + `Invalid deployment type: ${options.deploymentType as any}`, + ); + } +} + +async function handleLocalDev( + ctx: Context, + _options: { + teamSlug: string; + projectSlug: string; + localDevPort?: string | undefined; + }, +) { + return await ctx.crash(1, "fatal", "Local development is not supported yet."); +} + +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), + }); } diff --git a/npm-packages/convex/src/cli/dev.ts b/npm-packages/convex/src/cli/dev.ts index 25a86a7b..6cfd3ae0 100644 --- a/npm-packages/convex/src/cli/dev.ts +++ b/npm-packages/convex/src/cli/dev.ts @@ -101,6 +101,11 @@ export const dev = new Command("dev") .addOption(new Option("--override-auth-client ").hideHelp()) .addOption(new Option("--override-auth-username ").hideHelp()) .addOption(new Option("--override-auth-password ").hideHelp()) + .addOption( + new Option("--local", "Develop live against a locally running backend.") + .default(false) + .hideHelp(), + ) .showHelpAfterError() .action(async (cmdOptions) => { const ctx = oneoffContext; @@ -123,6 +128,11 @@ export const dev = new Command("dev") configure, cmdOptions, ); + if (credentials.cleanupHandle !== null) { + process.on("exit", () => { + void credentials.cleanupHandle?.(); + }); + } await usageStateWarning(ctx); diff --git a/npm-packages/convex/src/cli/lib/api.ts b/npm-packages/convex/src/cli/lib/api.ts index 115dfc42..46dbdca7 100644 --- a/npm-packages/convex/src/cli/lib/api.ts +++ b/npm-packages/convex/src/cli/lib/api.ts @@ -21,7 +21,7 @@ import { } from "./utils.js"; export type DeploymentName = string; -export type DeploymentType = "dev" | "prod"; +export type DeploymentType = "dev" | "prod" | "local"; export type Project = { id: string; @@ -32,6 +32,50 @@ export type Project = { type AdminKey = string; +// Provision a new empty project and return the slugs. +export async function createProject( + ctx: Context, + { + teamSlug: selectedTeamSlug, + projectName, + }: { teamSlug: string; projectName: string }, +): Promise<{ + projectSlug: string; + teamSlug: string; + projectsRemaining: number; +}> { + const provisioningArgs = { + team: selectedTeamSlug, + projectName, + // TODO: Consider allowing projects with no deployments, or consider switching + // to provisioning prod on creation. + deploymentType: "dev", + backendVersionOverride: process.env.CONVEX_BACKEND_VERSION_OVERRIDE, + }; + const data = await bigBrainAPI({ + ctx, + method: "POST", + url: "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); + logError(ctx, chalk.red(error)); + return await ctx.crash(1, "transient", error); + } + return { + projectSlug, + teamSlug, + projectsRemaining, + }; +} + // Init // Provision a new empty project and return the new deployment credentials. export async function createProjectProvisioningDevOrProd( @@ -518,7 +562,7 @@ export async function fetchDeploymentCredentialsProvisioningDevOrProdMaybeThrows { teamSlug, projectSlug }: { teamSlug: string; projectSlug: string }, deploymentType: DeploymentType, ): Promise<{ - deploymentName: string | undefined; + deploymentName: string; url: string; adminKey: AdminKey; }> { diff --git a/npm-packages/convex/src/cli/lib/components.ts b/npm-packages/convex/src/cli/lib/components.ts index 8e564efe..2c60c1d4 100644 --- a/npm-packages/convex/src/cli/lib/components.ts +++ b/npm-packages/convex/src/cli/lib/components.ts @@ -53,6 +53,7 @@ export async function runCodegen(ctx: Context, options: CodegenOptions) { const credentials = await deploymentCredentialsOrConfigure(ctx, null, { ...options, prod: false, + local: false, }); await startComponentsPushAndCodegen(ctx, projectConfig, configPath, { ...options, diff --git a/npm-packages/convex/src/cli/lib/deployment.ts b/npm-packages/convex/src/cli/lib/deployment.ts index e9b7f627..e4c3cc65 100644 --- a/npm-packages/convex/src/cli/lib/deployment.ts +++ b/npm-packages/convex/src/cli/lib/deployment.ts @@ -5,6 +5,7 @@ import { CONVEX_DEPLOY_KEY_ENV_VAR_NAME, readAdminKeyFromEnvVar, } from "./utils.js"; +import { DeploymentType } from "./api.js"; const ENV_VAR_FILE_PATH = ".env.local"; export const CONVEX_DEPLOYMENT_VAR_NAME = "CONVEX_DEPLOYMENT"; @@ -51,9 +52,9 @@ function getDeploymentTypeFromConfiguredDeployment(raw: string) { export async function writeDeploymentEnvVar( ctx: Context, - deploymentType: "dev" | "prod", + deploymentType: DeploymentType, deployment: { team: string; project: string; deploymentName: string }, -): Promise<{ wroteToGitIgnore: boolean }> { +): Promise<{ wroteToGitIgnore: boolean; changedDeploymentEnvVar: boolean }> { const existingFile = ctx.fs.exists(ENV_VAR_FILE_PATH) ? ctx.fs.readUtf8File(ENV_VAR_FILE_PATH) : null; @@ -64,14 +65,23 @@ export async function writeDeploymentEnvVar( ); // Also update process.env directly, because `dotfile.config()` doesn't pick // up changes to the file. - process.env[CONVEX_DEPLOYMENT_VAR_NAME] = + const existingValue = process.env[CONVEX_DEPLOYMENT_VAR_NAME]; + const deploymentEnvVarValue = deploymentType + ":" + deployment.deploymentName; + process.env[CONVEX_DEPLOYMENT_VAR_NAME] = deploymentEnvVarValue; + if (changedFile !== null) { ctx.fs.writeUtf8File(ENV_VAR_FILE_PATH, changedFile); // Only do this if we're not reinitializing an existing setup - return { wroteToGitIgnore: await gitIgnoreEnvVarFile(ctx) }; + return { + wroteToGitIgnore: await gitIgnoreEnvVarFile(ctx), + changedDeploymentEnvVar: existingValue !== deploymentEnvVarValue, + }; } - return { wroteToGitIgnore: false }; + return { + wroteToGitIgnore: false, + changedDeploymentEnvVar: existingValue !== deploymentEnvVarValue, + }; } // Only used in the internal --url flow @@ -111,7 +121,7 @@ async function gitIgnoreEnvVarFile(ctx: Context): Promise { // exported for tests export function changesToEnvVarFile( existingFile: string | null, - deploymentType: "dev" | "prod", + deploymentType: DeploymentType, { team, project, diff --git a/npm-packages/convex/src/cli/lib/init.ts b/npm-packages/convex/src/cli/lib/init.ts index 8f41e3ad..7e2e2935 100644 --- a/npm-packages/convex/src/cli/lib/init.ts +++ b/npm-packages/convex/src/cli/lib/init.ts @@ -1,169 +1,45 @@ import chalk from "chalk"; -import inquirer from "inquirer"; -import path from "path"; -import { - Context, - logFailure, - logFinishedStep, - logMessage, - logWarning, - showSpinner, -} from "../../bundler/context.js"; -import { projectDashboardUrl } from "../dashboard.js"; -import { DeploymentType, createProjectProvisioningDevOrProd } from "./api.js"; -import { doCodegen, doInitCodegen } from "./codegen.js"; -import { - configFilepath, - readProjectConfig, - upgradeOldAuthInfoToAuthConfig, - writeProjectConfig, -} from "./config.js"; -import { writeDeploymentEnvVar } from "./deployment.js"; +import { Context, logFinishedStep, logMessage } from "../../bundler/context.js"; +import { DeploymentType } from "./api.js"; import { writeConvexUrlToEnvFile } from "./envvars.js"; -import { - functionsDir, - logAndHandleFetchError, - validateOrSelectTeam, -} from "./utils.js"; - -const cwd = path.basename(process.cwd()); - -export async function init( - ctx: Context, - deploymentType: DeploymentType = "prod", - config: { - team?: string | undefined; - project?: string | undefined; - }, -) { - const configPath = await configFilepath(ctx); - - const { teamSlug: selectedTeam, chosen: didChooseBetweenTeams } = - await validateOrSelectTeam(ctx, config.team, "Team:"); - - let projectName: string = config.project || cwd; - if (process.stdin.isTTY && !config.project) { - projectName = ( - await inquirer.prompt([ - { - type: "input", - name: "project", - message: "Project name:", - default: cwd, - }, - ]) - ).project; - } - - showSpinner(ctx, "Creating new Convex project..."); - - let projectSlug, teamSlug, deploymentName, url, adminKey, projectsRemaining; - try { - ({ - projectSlug, - teamSlug, - deploymentName, - url, - adminKey, - projectsRemaining, - } = await createProjectProvisioningDevOrProd( - ctx, - { teamSlug: selectedTeam, projectName }, - deploymentType, - )); - } 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 functionsPath = functionsDir(configPath, existingProjectConfig); - - const { wroteToGitIgnore } = await writeDeploymentEnvVar( - ctx, - deploymentType, - { - team: teamSlug, - project: projectSlug, - deploymentName, - }, - ); - - const projectConfig = await upgradeOldAuthInfoToAuthConfig( - ctx, - existingProjectConfig, - functionsPath, - ); - await writeProjectConfig(ctx, projectConfig); - - await doInitCodegen(ctx, functionsPath, true); - - // Disable typechecking since there isn't any code yet. - await doCodegen(ctx, functionsPath, "disable"); - - await finalizeConfiguration( - ctx, - functionsPath, - deploymentType, - url, - wroteToGitIgnore, - ); - - return { deploymentName, adminKey, url }; -} export async function finalizeConfiguration( ctx: Context, - functionsPath: string, - deploymentType: DeploymentType, - url: string, - wroteToGitIgnore: boolean, + options: { + functionsPath: string; + deploymentType: DeploymentType; + url: string; + wroteToGitIgnore: boolean; + changedDeploymentEnvVar: boolean; + }, ) { - const envVarWrite = await writeConvexUrlToEnvFile(ctx, url); + const envVarWrite = await writeConvexUrlToEnvFile(ctx, options.url); if (envVarWrite !== null) { logFinishedStep( ctx, - `Provisioned a ${deploymentType} deployment and saved its:\n` + + `Provisioned a ${options.deploymentType} deployment and saved its:\n` + ` name as CONVEX_DEPLOYMENT to .env.local\n` + ` URL as ${envVarWrite.envVar} to ${envVarWrite.envFile}`, ); - } else { + } else if (options.changedDeploymentEnvVar) { logFinishedStep( ctx, - `Provisioned ${deploymentType} deployment and saved its name as CONVEX_DEPLOYMENT to .env.local`, + `Provisioned ${options.deploymentType} deployment and saved its name as CONVEX_DEPLOYMENT to .env.local`, ); } - if (wroteToGitIgnore) { + if (options.wroteToGitIgnore) { logMessage(ctx, chalk.gray(` Added ".env.local" to .gitignore`)); } - logMessage( - ctx, - `\nWrite your Convex functions in ${chalk.bold(functionsPath)}\n` + - "Give us feedback at https://convex.dev/community or support@convex.dev\n", - ); + const anyChanges = + options.wroteToGitIgnore || + options.changedDeploymentEnvVar || + envVarWrite !== null; + if (anyChanges) { + logMessage( + ctx, + `\nWrite your Convex functions in ${chalk.bold(options.functionsPath)}\n` + + "Give us feedback at https://convex.dev/community or support@convex.dev\n", + ); + } } diff --git a/npm-packages/convex/src/cli/lib/reinit.ts b/npm-packages/convex/src/cli/lib/reinit.ts deleted file mode 100644 index cc95a2a0..00000000 --- a/npm-packages/convex/src/cli/lib/reinit.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Context, logFailure, showSpinner } from "../../bundler/context.js"; -import { - DeploymentType, - fetchDeploymentCredentialsProvisioningDevOrProd, -} from "./api.js"; -import { doCodegen } from "./codegen.js"; -import { - configName, - readProjectConfig, - upgradeOldAuthInfoToAuthConfig, - writeProjectConfig, -} from "./config.js"; -import { writeDeploymentEnvVar } from "./deployment.js"; -import { finalizeConfiguration } from "./init.js"; -import { - functionsDir, - validateOrSelectProject, - validateOrSelectTeam, -} from "./utils.js"; - -export async function reinit( - ctx: Context, - deploymentType: DeploymentType = "prod", - config: { - team?: string | undefined; - project?: string | undefined; - }, -) { - const { teamSlug } = await validateOrSelectTeam(ctx, config.team, "Team:"); - - const projectSlug = await validateOrSelectProject( - ctx, - config.project, - teamSlug, - "Configure project", - "Project:", - ); - if (!projectSlug) { - logFailure(ctx, "Run the command again to create a new project instead."); - await ctx.crash(1); - return; - } - - showSpinner(ctx, `Reinitializing project ${projectSlug}...\n`); - - const { deploymentName, url, adminKey } = - await fetchDeploymentCredentialsProvisioningDevOrProd( - ctx, - { teamSlug, projectSlug }, - deploymentType, - ); - - const { configPath, projectConfig: existingProjectConfig } = - await readProjectConfig(ctx); - - const functionsPath = functionsDir(configName(), existingProjectConfig); - - const { wroteToGitIgnore } = await writeDeploymentEnvVar( - ctx, - deploymentType, - { - team: teamSlug, - project: projectSlug, - deploymentName: deploymentName!, - }, - ); - - const projectConfig = await upgradeOldAuthInfoToAuthConfig( - ctx, - existingProjectConfig, - functionsPath, - ); - await writeProjectConfig(ctx, projectConfig, { - deleteIfAllDefault: true, - }); - - await doCodegen(ctx, functionsDir(configPath, projectConfig), "disable"); - - await finalizeConfiguration( - ctx, - functionsDir(configPath, projectConfig), - deploymentType, - url, - wroteToGitIgnore, - ); - - return { deploymentName, url, adminKey }; -}