diff --git a/.changeset/serious-doors-call.md b/.changeset/serious-doors-call.md new file mode 100644 index 0000000000..4cb0e1a189 --- /dev/null +++ b/.changeset/serious-doors-call.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Added new CLI command to list and view environment variables diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts index bea6eacd04..fc482224e5 100644 --- a/packages/cli-v3/src/cli/index.ts +++ b/packages/cli-v3/src/cli/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { configureAnalyzeCommand } from "../commands/analyze.js"; import { configureDeployCommand } from "../commands/deploy.js"; import { configureDevCommand } from "../commands/dev.js"; +import { configureEnvCommand } from "../commands/env.js"; import { configureInitCommand } from "../commands/init.js"; import { configureListProfilesCommand } from "../commands/list-profiles.js"; import { configureLoginCommand } from "../commands/login.js"; @@ -28,6 +29,7 @@ program configureLoginCommand(program); configureInitCommand(program); configureDevCommand(program); +configureEnvCommand(program); configureDeployCommand(program); configurePromoteCommand(program); configureWhoamiCommand(program); diff --git a/packages/cli-v3/src/commands/env.ts b/packages/cli-v3/src/commands/env.ts new file mode 100644 index 0000000000..7a21218754 --- /dev/null +++ b/packages/cli-v3/src/commands/env.ts @@ -0,0 +1,441 @@ +import { Command } from "commander"; +import { z } from "zod"; +import { printInitialBanner } from "../utilities/initialBanner.js"; +import { + CommonCommandOptions, + commonOptions, + handleTelemetry, + wrapCommandAction, +} from "../cli/common.js"; +import { intro, outro, log, confirm, isCancel } from "@clack/prompts"; +import chalk from "chalk"; +import Table from "cli-table3"; +import { logger } from "../utilities/logger.js"; +import { login } from "./login.js"; +import { getProjectClient, upsertBranch } from "../utilities/session.js"; +import { loadConfig } from "../config.js"; +import { spinner } from "../utilities/windows.js"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { tryCatch } from "@trigger.dev/core"; + +const EnvListOptions = CommonCommandOptions.extend({ + config: z.string().optional(), + projectRef: z.string().optional(), + showValues: z.boolean().default(false), + env: z.enum(["prod", "staging", "preview", "production"]).default("prod"), + branch: z.string().optional(), +}); + +const EnvGetOptions = CommonCommandOptions.extend({ + config: z.string().optional(), + projectRef: z.string().optional(), + name: z.string(), + raw: z.boolean().default(false), + env: z.enum(["prod", "staging", "preview", "production"]).default("prod"), + branch: z.string().optional(), +}); + +const EnvPullOptions = CommonCommandOptions.extend({ + config: z.string().optional(), + projectRef: z.string().optional(), + output: z.string().default(".env.local"), + force: z.boolean().default(false), + env: z.enum(["prod", "staging", "preview", "production"]).default("prod"), + branch: z.string().optional(), +}); + +export function configureEnvCommand(program: Command) { + const envCommand = program + .command("env") + .description("Manage environment variables for your Trigger.dev project"); + + commonOptions( + envCommand + .command("list") + .description("List all environment variables for your project") + .option("-c, --config ", "The name of the config file") + .option( + "-p, --project-ref ", + "The project ref. Required if there is no config file." + ) + .option( + "-e, --env ", + "The environment to list variables from (prod, staging, preview)", + "prod" + ) + .option("-b, --branch ", "The preview branch when using --env preview") + .option( + "--show-values", + "Show the actual values of environment variables, including secret values" + ) + ).action(async (options) => { + await handleTelemetry(async () => { + await printInitialBanner(false); + await envListCommand(options); + }); + }); + + commonOptions( + envCommand + .command("get ") + .description("Get the value of a specific environment variable") + .option("-c, --config ", "The name of the config file") + .option( + "-p, --project-ref ", + "The project ref. Required if there is no config file" + ) + .option( + "-e, --env ", + "The environment to get the variable from (prod, staging, preview)", + "prod" + ) + .option("-b, --branch ", "The preview branch when using --env preview") + .option("--raw", "Only output the raw value without any formatting or additional information") + ).action(async (name, options) => { + await handleTelemetry(async () => { + if (!options.raw) { + await printInitialBanner(false); + } + await envGetCommand({ ...options, name }); + }); + }); + + commonOptions( + envCommand + .command("pull") + .description("Pull environment variables from your project to a local file") + .option("-c, --config ", "The name of the config file") + .option( + "-p, --project-ref ", + "The project ref. Required if there is no config file" + ) + .option( + "-e, --env ", + "The environment to pull variables from (prod, staging, preview)", + "prod" + ) + .option("-b, --branch ", "The preview branch when using --env preview") + .option("-o, --output ", "Output file path", ".env.local") + .option("--force", "Overwrite the output file if it exists") + ).action(async (options) => { + await handleTelemetry(async () => { + await printInitialBanner(false); + await envPullCommand(options); + }); + }); + + return envCommand; +} + +async function envListCommand(options: unknown) { + return await wrapCommandAction( + "envList", + EnvListOptions, + options, + async (opts: z.infer) => { + return await _envListCommand(opts); + } + ); +} + +async function envGetCommand(options: unknown) { + return await wrapCommandAction( + "envGet", + EnvGetOptions, + options, + async (opts: z.infer) => { + return await _envGetCommand(opts); + } + ); +} + +async function envPullCommand(options: unknown) { + return await wrapCommandAction( + "envPull", + EnvPullOptions, + options, + async (opts: z.infer) => { + return await _envPullCommand(opts); + } + ); +} + +async function resolveProjectEnv( + options: + | z.infer + | z.infer + | z.infer +) { + const authorization = await login({ + embedded: true, + defaultApiUrl: options.apiUrl, + profile: options.profile, + silent: "raw" in options ? options.raw : false, + }); + + if (!authorization.ok) { + if (authorization.error === "fetch failed") { + throw new Error( + `Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?` + ); + } + + throw new Error(`You must login first. Use the \`login\` CLI command.`); + } + + const resolvedConfig = await loadConfig({ + overrides: { project: options.projectRef }, + configFile: options.config, + }); + + logger.debug("Resolved config", resolvedConfig); + + // Coerce production to prod + const env = options.env === "production" ? "prod" : options.env; + + if (env === "preview" && !options.branch) { + throw new Error("Missing branch for the preview environment."); + } + + const projectClient = await getProjectClient({ + accessToken: authorization.auth.accessToken, + apiUrl: authorization.auth.apiUrl, + projectRef: resolvedConfig.project, + env, + branch: options.branch, + profile: options.profile, + }); + + if (!projectClient) { + throw new Error("Failed to get project client"); + } + + return { + projectClient, + projectRef: resolvedConfig.project, + env, + branch: options.branch, + }; +} + +async function _envListCommand(options: z.infer) { + intro("Environment Variables"); + + const $spinner = spinner(); + + const { projectClient, projectRef, env, branch } = await resolveProjectEnv(options); + + $spinner.start("Loading environment variables from project"); + const envVars = await projectClient.client.getEnvironmentVariables(projectRef); + + if (!envVars.success) { + $spinner.stop("Failed loading environment variables"); + throw envVars.error; + } + + $spinner.stop("Environment variables loaded"); + + const variables = envVars.data.variables; + + // Filter out TRIGGER_ system variables to only show user-set variables. + // The current envvars endpoint doesn't support filtering, so we just do basic filtering on the client side. + // We'll soon add a v2 of this endpoint which supports filtering and also includes more info about the variables. + const userVariables = Object.entries(variables).filter(([key]) => !key.startsWith("TRIGGER_")); + + if (userVariables.length === 0) { + log.info("No environment variables found"); + const envInfo = branch ? `${env} (${branch})` : env; + outro(`Project: ${projectRef} | Environment: ${envInfo}`); + return; + } + + const table = new Table({ + head: ["Variable", options.showValues ? "Value" : "Value (hidden)"], + style: { + head: ["yellow"], + }, + chars: { + top: "", + "top-mid": "", + "top-left": "", + "top-right": "", + bottom: "", + "bottom-mid": "", + "bottom-left": "", + "bottom-right": "", + left: "", + "left-mid": "", + mid: "", + "mid-mid": "", + right: "", + "right-mid": "", + middle: " ", + }, + }); + + for (const [key, value] of userVariables) { + table.push([key, options.showValues ? value : "******"]); + } + + console.log(); + console.log(table.toString()); + console.log(); + + if (!options.showValues) { + log.info(chalk.dim("Use --show-values to display the actual values")); + } + + const envInfo = branch ? `${env} (${branch})` : env; + outro( + `Found ${userVariables.length} environment variable${ + userVariables.length === 1 ? "" : "s" + } | Project: ${projectRef} | Environment: ${envInfo}` + ); +} + +async function _envGetCommand(options: z.infer) { + const $spinner = options.raw ? null : spinner(); + + if (!options.raw) { + intro(`Getting environment variable: ${options.name}`); + } + + const { projectClient, projectRef, env, branch } = await resolveProjectEnv(options); + + $spinner?.start("Loading environment variables from project"); + const envVars = await projectClient.client.getEnvironmentVariables(projectRef); + + if (!envVars.success) { + $spinner?.stop("Failed loading environment variables"); + throw new Error(`Failed to load environment variables: ${envVars.error}`); + } + + $spinner?.stop("Environment variables loaded"); + + const variables = envVars.data.variables; + + const value = variables[options.name]; + + if (value === undefined) { + if (options.raw) { + throw new Error(`Environment variable "${options.name}" not found`); + } + + log.error(chalk.red(`Environment variable '${options.name}' not found`)); + + // Suggest similar variables if any exist + const keys = Object.keys(variables); + const similar = keys.filter( + (k: string) => + k.toLowerCase().includes(options.name.toLowerCase()) || + options.name.toLowerCase().includes(k.toLowerCase()) + ); + + if (similar.length > 0) { + log.info(chalk.dim("Did you mean one of these?")); + similar.forEach((s: string) => log.info(chalk.dim(` - ${s}`))); + } + + const envInfo = branch ? `${env} (${branch})` : env; + outro(`Project: ${projectRef} | Environment: ${envInfo}`); + process.exit(1); + } + + if (options.raw) { + console.log(value || ""); + return; + } + + log.success(chalk.green(`${options.name}=${value}`)); + + const envInfo = branch ? `${env} (${branch})` : env; + outro(`Project: ${projectRef} | Environment: ${envInfo}`); +} + +async function _envPullCommand(options: z.infer) { + intro("Pull Environment Variables"); + const $spinner = spinner(); + + const { projectClient, projectRef, env, branch } = await resolveProjectEnv(options); + + $spinner.start("Loading environment variables from project"); + + const envVars = await projectClient.client.getEnvironmentVariables(projectRef); + + if (!envVars.success) { + $spinner.stop("Failed loading environment variables"); + throw envVars.error; + } + + $spinner.stop("Environment variables loaded"); + + const variables = envVars.data.variables; + // Filter out TRIGGER_ system variables to only show user-set variables. + // The current envvars endpoint doesn't support filtering, so we just do basic filtering on the client side. + // We'll soon add a v2 of this endpoint which supports filtering and also includes more info about the variables. + const userVariables = Object.entries(variables).filter(([key]) => !key.startsWith("TRIGGER_")); + + if (userVariables.length === 0) { + log.info("No environment variables found"); + const envInfo = branch ? `${env} (${branch})` : env; + outro(`Project: ${projectRef} | Environment: ${envInfo}`); + return; + } + + const outputPath = resolve(process.cwd(), options.output); + + const [error] = await tryCatch(writeFile(outputPath, "", { flag: "wx", mode: 0o600 })); + + if (error && "code" in error && error.code !== "EEXIST") { + throw error; + } + + if (error && "code" in error && error.code === "EEXIST" && !options.force) { + const shouldOverwrite = await confirm({ + message: `File ${options.output} already exists. Overwrite?`, + initialValue: false, + }); + + if (isCancel(shouldOverwrite) || !shouldOverwrite) { + outro("Cancelled"); + return; + } + } + + const envContent = userVariables + .map(([key, value]) => `${key}=${serializeDotenvValue(value)}`) + .join("\n"); + + $spinner.start(`Writing to ${options.output}`); + const [writeError] = await tryCatch( + writeFile(outputPath, envContent + "\n", { encoding: "utf-8", mode: 0o600 }) + ); + + if (writeError) { + $spinner.stop(`Failed to write to ${options.output}`); + throw writeError; + } + + $spinner.stop(`Written to ${options.output}`); + + log.success( + chalk.green( + `Pulled ${userVariables.length} environment variable${ + userVariables.length === 1 ? "" : "s" + } into ${options.output}` + ) + ); + + const envInfo = branch ? `${env} (${branch})` : env; + outro(`Project: ${projectRef} | Environment: ${envInfo}`); +} + +const serializeDotenvValue = (v: unknown): string => { + if (v == null || v === undefined) return ""; + + const s = String(v); + // Quote when unsafe chars present: whitespace, equals, newlines, comments, quotes, backslashes + const needsQuotes = /[\s#"'`\\=\n\r]/.test(s) || s === ""; + return needsQuotes ? JSON.stringify(s) : s; +};