From 29e94a6a6401a35a2a7f425e7627f1b86b392e96 Mon Sep 17 00:00:00 2001 From: Charles Buffington Date: Sun, 5 Oct 2025 13:59:59 -0400 Subject: [PATCH] Add "env set-from-file" command to CLI --- src/cli/env.ts | 19 +++++++++++++++ src/cli/lib/env.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/cli/env.ts b/src/cli/env.ts index 4726bd9..de5df5b 100644 --- a/src/cli/env.ts +++ b/src/cli/env.ts @@ -12,6 +12,7 @@ import { envGetInDeploymentAction, envListInDeployment, envRemoveInDeployment, + envSetFromFileInDeployment, envSetInDeployment, } from "./lib/env.js"; import { getDeploymentSelection } from "./lib/deploymentSelection.js"; @@ -36,6 +37,23 @@ const envSet = new Command("set") await envSetInDeployment(ctx, deployment, originalName, originalValue); }); +const envSetFromFile = new Command("set-from-file") + .usage("[options] ") + .arguments("") + .summary("Set variables from a file") + .description( + "Set variables from a file: `npx convex env set-from-file FILE`\n" + + "The file should contain lines of the form `NAME=value`." + ) + .configureHelp({ showGlobalOptions: true }) + .allowExcessArguments(false) + .action(async (file, _options, cmd) => { + const options = cmd.optsWithGlobals(); + const { ctx, deployment } = await selectEnvDeployment(options); + await ensureHasConvexDependency(ctx, "env set-from-file"); + await envSetFromFileInDeployment(ctx, deployment, file); + }); + async function selectEnvDeployment( options: DeploymentSelectionOptions, ): Promise<{ @@ -127,6 +145,7 @@ export const env = new Command("env") "By default, this sets and views variables on your dev deployment.", ) .addCommand(envSet) + .addCommand(envSetFromFile) .addCommand(envGet) .addCommand(envRemove) .addCommand(envList) diff --git a/src/cli/lib/env.ts b/src/cli/lib/env.ts index 0fb6d44..b0820c5 100644 --- a/src/cli/lib/env.ts +++ b/src/cli/lib/env.ts @@ -35,6 +35,24 @@ export async function envSetInDeployment( } } +export async function envSetFromFileInDeployment( + ctx: Context, + deployment: { + deploymentUrl: string; + adminKey: string; + deploymentNotice: string; + }, + file: string, +) { + const changes = await parseEnvFile(ctx, file); + if (changes.length === 0) { + logMessage(`No environment variables to set in file "${file}".`); + return; + } + await callUpdateEnvironmentVariables(ctx, deployment, changes); + logFinishedStep(`Successfully set ${chalk.bold(changes.length.toString())} environment variable(s) from file "${file}"${deployment.deploymentNotice}`); +} + async function allowEqualsSyntax( ctx: Context, name: string, @@ -66,6 +84,49 @@ async function allowEqualsSyntax( return [name, value]; } +async function parseEnvFile( + ctx: Context, + file: string, +): Promise { + let fileContents: string; + try { + fileContents = ctx.fs.readUtf8File(file); + } catch (e) { + return await ctx.crash({ + exitCode: 1, + errorType: "fatal", + printedMessage: `error: failed to read file "${file}": ${e instanceof Error ? e.message : String(e)}`, + }); + } + const changes: EnvVarChange[] = []; + const lines = fileContents.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "" || line.startsWith("#")) { + continue; + } + const eqIndex = line.indexOf("="); + if (eqIndex === -1) { + return await ctx.crash({ + exitCode: 1, + errorType: "fatal", + printedMessage: `error: invalid line ${i + 1} in file "${file}": missing '=' separator`, + }); + } + const name = line.slice(0, eqIndex).trim(); + const value = line.slice(eqIndex + 1).trim(); + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + return await ctx.crash({ + exitCode: 1, + errorType: "fatal", + printedMessage: `error: invalid environment variable name "${name}" on line ${i + 1} in file "${file}"`, + }); + } + changes.push({ name, value }); + } + return changes; +} + export async function envGetInDeploymentAction( ctx: Context, deployment: {