UNPKG

convex

Version:

Client for the Convex Cloud

391 lines (361 loc) 11.1 kB
import { chalkStderr } from "chalk"; import * as dotenv from "dotenv"; import { Context } from "../../bundler/context.js"; import { logFailure, logFinishedStep, logMessage, logOutput, } from "../../bundler/log.js"; import { runSystemQuery } from "./run.js"; import { deploymentFetch, logAndHandleFetchError } from "./utils/utils.js"; import { readFromStdin } from "./utils/stdin.js"; import { promptSecret } from "./utils/prompts.js"; import { EXPECTED_CONVEX_URL_NAMES, EXPECTED_SITE_URL_NAMES, } from "./envvars.js"; import { formatEnvValueForDotfile } from "./formatEnvValueForDotfile.js"; import { CONVEX_DEPLOY_KEY_ENV_VAR_NAME, CONVEX_DEPLOYMENT_ENV_VAR_NAME, CONVEX_SELF_HOSTED_URL_VAR_NAME, CONVEX_SELF_HOSTED_ADMIN_KEY_VAR_NAME, } from "./utils/utils.js"; function formatList(items: string[]): string { if (items.length === 0) return ""; if (items.length === 1) return items[0]; if (items.length === 2) return `${items[0]} and ${items[1]}`; return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`; } export async function envSetInDeployment( ctx: Context, deployment: { deploymentUrl: string; adminKey: string; deploymentNotice: string; }, originalName: string | undefined, originalValue: string | undefined, options?: { fromFile?: string; force?: boolean; secret?: boolean; }, ) { const { fromFile, force = false } = options ?? {}; if (originalName) { let name = originalName, value: string; const parsed = await allowEqualsSyntax(ctx, originalName, originalValue); if (parsed) { [name, value] = parsed; } else if (fromFile) { value = await getFileContents(ctx, fromFile); } else if (!process.stdin.isTTY) { value = await getStdIn(ctx); } else { value = await promptSecret(ctx, { message: `Enter value for ${name}:`, }); } await callUpdateEnvironmentVariables(ctx, deployment, [{ name, value }]); if (options?.secret) { const formatted = /\s/.test(value) ? `"${value}"` : value; logFinishedStep( `Successfully set ${chalkStderr.bold(name)} to ${chalkStderr.bold(formatted)}${deployment.deploymentNotice}`, ); } else { logFinishedStep(`Successfully set ${chalkStderr.bold(name)}`); } return true; } let content: string, source: string; if (fromFile) { content = await getFileContents(ctx, fromFile); source = fromFile; } else if (!process.stdin.isTTY) { content = await getStdIn(ctx); source = "stdin"; } else { return false; } await envSetFromContentInDeployment(ctx, deployment, { content, source, force, }); return true; } async function getFileContents( ctx: Context, filePath: string, ): Promise<string> { if (!ctx.fs.exists(filePath)) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `error: file not found: ${filePath}`, }); } return ctx.fs.readUtf8File(filePath); } async function getStdIn(ctx: Context): Promise<string> { try { return await readFromStdin(); } catch (error) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `error: failed to read from stdin: ${error instanceof Error ? error.message : String(error)}`, }); } } async function envSetFromContentInDeployment( ctx: Context, deployment: { deploymentUrl: string; adminKey: string; deploymentNotice: string; }, options: { content: string; source: string; force: boolean; }, ) { const { content, source, force } = options; const parsedEnv = dotenv.parse(content); // Filter out CLI-managed environment variables const envVars = Object.entries(parsedEnv); const filteredVars: string[] = []; const envVarsToSet: [string, string][] = []; const managedVars = new Set<string>([ CONVEX_DEPLOY_KEY_ENV_VAR_NAME, CONVEX_DEPLOYMENT_ENV_VAR_NAME, CONVEX_SELF_HOSTED_URL_VAR_NAME, CONVEX_SELF_HOSTED_ADMIN_KEY_VAR_NAME, ...EXPECTED_CONVEX_URL_NAMES, ...EXPECTED_SITE_URL_NAMES, ]); for (const [name, value] of envVars) { if (managedVars.has(name)) { filteredVars.push(name); } else { envVarsToSet.push([name, value]); } } if (filteredVars.length > 0) { const varNames = filteredVars.map((n) => chalkStderr.bold(n)); const formattedNames = formatList(varNames); logMessage( `Skipping ${filteredVars.length} CLI-managed environment variable${filteredVars.length === 1 ? "" : "s"}: ${formattedNames}`, ); } if (envVarsToSet.length === 0) { if (envVars.length === 0) { logMessage(`No environment variables found in ${source}.`); } return; } // Fetch existing environment variables const existingEnvVars = await getEnvVars(ctx, deployment); const existingEnvMap = new Map( existingEnvVars.map((env) => [env.name, env.value]), ); // Categorize the environment variables const newVars: [string, string][] = []; const updatedVars: [string, string][] = []; const unchangedVars: [string, string][] = []; const conflicts: { name: string; existing: string; new: string }[] = []; for (const [name, value] of envVarsToSet) { const existingValue = existingEnvMap.get(name); if (existingValue === undefined) { newVars.push([name, value]); } else if (existingValue === value) { unchangedVars.push([name, value]); } else if (force) { updatedVars.push([name, value]); } else { conflicts.push({ name, existing: existingValue, new: value }); } } // Check for conflicts if not replacing if (conflicts.length > 0) { const varNames = conflicts.map((c) => chalkStderr.bold(c.name)); const formattedNames = formatList(varNames); return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `error: environment variable${conflicts.length === 1 ? "" : "s"} ${formattedNames} already exist${conflicts.length === 1 ? "s" : ""} with different value${conflicts.length === 1 ? "" : "s"}.\n\n` + `Use ${chalkStderr.bold("--force")} to overwrite existing values.`, }); } // Build the changes: only new vars when not replacing, new + updated when replacing const varsToUpdate = force ? [...newVars, ...updatedVars] : newVars; const changes: EnvVarChange[] = varsToUpdate.map(([name, value]) => ({ name, value, })); if (changes.length > 0) { await callUpdateEnvironmentVariables(ctx, deployment, changes); } const newCount = newVars.length; const updatedCount = updatedVars.length; const unchangedCount = unchangedVars.length; const parts = []; if (newCount > 0) parts.push(`${newCount} new`); if (updatedCount > 0) parts.push(`${updatedCount} updated`); if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`); const totalProcessed = newCount + updatedCount + unchangedCount; if (changes.length === 0) { logMessage( `All ${totalProcessed} environment variable${totalProcessed === 1 ? "" : "s"} from ${chalkStderr.bold(source)} already set${deployment.deploymentNotice}`, ); } else { logFinishedStep( `Successfully set ${changes.length} environment variable${changes.length === 1 ? "" : "s"} from ${chalkStderr.bold(source)} (${parts.join(", ")})${deployment.deploymentNotice}`, ); } } async function allowEqualsSyntax( ctx: Context, name: string, value: string | undefined, ): Promise<[string, string] | null> { if (/^[a-zA-Z][a-zA-Z0-9_]*=/.test(name)) { const [n, ...values] = name.split("="); if (value === undefined) { return [n, values.join("=")]; } else { await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `When setting an environment variable, you can either set a value with 'NAME=value', or with NAME value, but not both. Are you missing quotes around the CLI argument? Try: \n npx convex env set '${name} ${value}'`, }); } } if (value === undefined) return null; return [name, value]; } export async function envGetInDeploymentAction( ctx: Context, deployment: { deploymentUrl: string; adminKey: string; }, name: string, ) { const envVar = await envGetInDeployment(ctx, deployment, name); if (envVar === null) { logFailure(`Environment variable "${name}" not found.`); return; } logOutput(`${envVar}`); } export async function envGetInDeployment( ctx: Context, deployment: { deploymentUrl: string; adminKey: string; }, name: string, ): Promise<string | null> { const envVar = (await runSystemQuery(ctx, { ...deployment, functionName: "_system/cli/queryEnvironmentVariables:get", componentPath: undefined, args: { name }, })) as EnvVar | null; return envVar === null ? null : envVar.value; } export async function envRemoveInDeployment( ctx: Context, deployment: { deploymentUrl: string; adminKey: string; deploymentNotice: string; }, name: string, ) { await callUpdateEnvironmentVariables(ctx, deployment, [{ name }]); logFinishedStep( `Successfully unset ${chalkStderr.bold(name)}${deployment.deploymentNotice}`, ); } async function getEnvVars( ctx: Context, deployment: { deploymentUrl: string; adminKey: string; }, ): Promise<EnvVar[]> { return (await runSystemQuery(ctx, { ...deployment, functionName: "_system/cli/queryEnvironmentVariables", componentPath: undefined, args: {}, })) as EnvVar[]; } export async function envListInDeployment( ctx: Context, deployment: { deploymentUrl: string; adminKey: string; }, ) { const envs = await getEnvVars(ctx, deployment); if (envs.length === 0) { logMessage("No environment variables set."); return; } for (const { name, value } of envs) { const { formatted, warning } = formatEnvValueForDotfile(value); if (warning) { logMessage(`Warning (${name}): ${warning}`); } logOutput(`${name}=${formatted}`); } } export type EnvVarChange = { name: string; value?: string; }; export type EnvVar = { name: string; value: string; }; export async function callUpdateEnvironmentVariables( ctx: Context, deployment: { deploymentUrl: string; adminKey: string; deploymentNotice: string; }, changes: EnvVarChange[], ) { const fetch = deploymentFetch(ctx, deployment); try { await fetch("/api/update_environment_variables", { body: JSON.stringify({ changes }), method: "POST", }); } catch (e) { return await logAndHandleFetchError(ctx, e); } } export async function fetchDeploymentCanonicalSiteUrl( ctx: Context, options: { deploymentUrl: string; adminKey: string }, ): Promise<string> { const result = await envGetInDeployment(ctx, options, "CONVEX_SITE_URL"); if (typeof result !== "string") { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem or env vars", printedMessage: "Invalid process.env.CONVEX_SITE_URL", }); } return result; }