From 976d76075533b0190f1637bd98f5ad03c07a02a8 Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Sun, 24 Aug 2025 08:35:20 +0200 Subject: [PATCH 01/12] Add support for dotenvx Signed-off-by: Alexis Rico --- packages/cli/package.json | 1 + packages/cli/src/commands/env/command.ts | 5 - packages/cli/src/commands/env/pull.ts | 133 +++++------- packages/cli/src/commands/pull/index.ts | 1 - packages/cli/src/util/env/diff-env-files.ts | 26 --- .../src/util/telemetry/commands/env/pull.ts | 6 - .../.env.local | 8 + .../.gitignore | 1 + .../.vercel/project.json | 4 + .../.env.local | 2 + .../.gitignore | 1 + .../.vercel/project.json | 4 + .../vercel-env-pull-with-encryption/.env.keys | 5 + .../.gitignore | 2 + .../.vercel/project.json | 4 + .../cli/test/unit/commands/env/pull.test.ts | 194 +++++++++++++++--- pnpm-lock.yaml | 100 +++++++-- 17 files changed, 325 insertions(+), 172 deletions(-) create mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.env.local create mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.gitignore create mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.vercel/project.json create mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.env.local create mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.gitignore create mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.vercel/project.json create mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.env.keys create mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.gitignore create mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.vercel/project.json diff --git a/packages/cli/package.json b/packages/cli/package.json index 48a4005fd09d..d8b4c6a676f8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "node": ">= 18" }, "dependencies": { + "@dotenvx/dotenvx": "1.49.0", "@vercel/build-utils": "10.5.1", "@vercel/fun": "1.1.6", "@vercel/go": "3.2.1", diff --git a/packages/cli/src/commands/env/command.ts b/packages/cli/src/commands/env/command.ts index 990daf15a9a7..3eaa3cc4e9c5 100644 --- a/packages/cli/src/commands/env/command.ts +++ b/packages/cli/src/commands/env/command.ts @@ -166,11 +166,6 @@ export const pullSubcommand = { argument: 'NAME', deprecated: false, }, - { - ...yesOption, - description: - 'Skip the confirmation prompt when removing an environment variable', - }, ], examples: [ { diff --git a/packages/cli/src/commands/env/pull.ts b/packages/cli/src/commands/env/pull.ts index ddce36a20c66..91cd4dee874b 100644 --- a/packages/cli/src/commands/env/pull.ts +++ b/packages/cli/src/commands/env/pull.ts @@ -1,57 +1,31 @@ +import * as dotenvx from '@dotenvx/dotenvx'; +import type { ProjectLinked } from '@vercel-internals/types'; import chalk from 'chalk'; +import { existsSync, readFileSync } from 'fs'; import { outputFile } from 'fs-extra'; -import { closeSync, openSync, readSync } from 'fs'; -import { resolve } from 'path'; +import { join, resolve } from 'path'; +import output from '../../output-manager'; import type Client from '../../util/client'; import { emoji, prependEmoji } from '../../util/emoji'; -import param from '../../util/output/param'; -import stamp from '../../util/output/stamp'; -import { getCommandName } from '../../util/pkg-name'; +import { buildDeltaString } from '../../util/env/diff-env-files'; import { type EnvRecordsSource, pullEnvRecords, } from '../../util/env/get-env-records'; -import { - buildDeltaString, - createEnvObject, -} from '../../util/env/diff-env-files'; -import { isErrnoException } from '@vercel/error-utils'; -import { addToGitIgnore } from '../../util/link/add-to-gitignore'; -import JSONparse from 'json-parse-better-errors'; -import { formatProject } from '../../util/projects/format-project'; -import type { ProjectLinked } from '@vercel-internals/types'; -import output from '../../output-manager'; -import { EnvPullTelemetryClient } from '../../util/telemetry/commands/env/pull'; -import { pullSubcommand } from './command'; +import { printError } from '../../util/error'; import { parseArguments } from '../../util/get-args'; import { getFlagsSpecification } from '../../util/get-flags-specification'; -import { printError } from '../../util/error'; +import { addToGitIgnore } from '../../util/link/add-to-gitignore'; +import stamp from '../../util/output/stamp'; import parseTarget from '../../util/parse-target'; +import { getCommandName } from '../../util/pkg-name'; +import { formatProject } from '../../util/projects/format-project'; import { getLinkedProject } from '../../util/projects/link'; +import { EnvPullTelemetryClient } from '../../util/telemetry/commands/env/pull'; +import { pullSubcommand } from './command'; const CONTENTS_PREFIX = '# Created by Vercel CLI\n'; -function readHeadSync(path: string, length: number) { - const buffer = Buffer.alloc(length); - const fd = openSync(path, 'r'); - try { - readSync(fd, buffer, 0, buffer.length, null); - } finally { - closeSync(fd); - } - return buffer.toString(); -} - -function tryReadHeadSync(path: string, length: number) { - try { - return readHeadSync(path, length); - } catch (err: unknown) { - if (!isErrnoException(err) || err.code !== 'ENOENT') { - throw err; - } - } -} - const VARIABLES_TO_IGNORE = [ 'VERCEL_ANALYTICS_ID', 'VERCEL_SPEED_INSIGHTS_ID', @@ -86,11 +60,9 @@ export default async function pull(client: Client, argv: string[]) { // handle relative or absolute filename const [rawFilename] = args; const filename = rawFilename || '.env.local'; - const skipConfirmation = opts['--yes']; const gitBranch = opts['--git-branch']; telemetryClient.trackCliArgumentFilename(args[0]); - telemetryClient.trackCliFlagYes(skipConfirmation); telemetryClient.trackCliOptionGitBranch(gitBranch); telemetryClient.trackCliOptionEnvironment(opts['--environment']); @@ -117,7 +89,6 @@ export default async function pull(client: Client, argv: string[]) { await envPullCommandLogic( client, filename, - !!skipConfirmation, environment, link, gitBranch, @@ -131,7 +102,6 @@ export default async function pull(client: Client, argv: string[]) { export async function envPullCommandLogic( client: Client, filename: string, - skipConfirmation: boolean, environment: string, link: ProjectLinked, gitBranch: string | undefined, @@ -139,22 +109,7 @@ export async function envPullCommandLogic( source: EnvRecordsSource ) { const fullPath = resolve(cwd, filename); - const head = tryReadHeadSync(fullPath, Buffer.byteLength(CONTENTS_PREFIX)); - const exists = typeof head !== 'undefined'; - - if (head === CONTENTS_PREFIX) { - output.log(`Overwriting existing ${chalk.bold(filename)} file`); - } else if ( - exists && - !skipConfirmation && - !(await client.input.confirm( - `Found existing file ${param(filename)}. Do you want to overwrite?`, - false - )) - ) { - output.log('Canceled'); - return; - } + const exists = existsSync(fullPath); const projectSlugLink = formatProject(link.org.slug, link.project.name); @@ -175,29 +130,45 @@ export async function envPullCommandLogic( ).env; let deltaString = ''; - let oldEnv; + let oldEnv: Record = {}; + if (exists) { - oldEnv = await createEnvObject(fullPath); - if (oldEnv) { - // Removes any double quotes from `records`, if they exist - // We need this because double quotes are stripped from the local .env file, - // but `records` is already in the form of a JSON object that doesn't filter - // double quotes. - const newEnv = JSONparse(JSON.stringify(records).replace(/\\"/g, '')); - deltaString = buildDeltaString(oldEnv, newEnv); + try { + const fileContent = readFileSync(fullPath, 'utf8'); + oldEnv = dotenvx.parse(fileContent, { processEnv: {} }); + } catch (error) { + throw new Error( + `Failed to parse existing env file at ${fullPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } else { + try { + await outputFile(fullPath, CONTENTS_PREFIX, 'utf8'); + } catch (error) { + throw new Error( + `Failed to create env file at ${fullPath}: ${error instanceof Error ? error.message : String(error)}` + ); } } - const contents = - CONTENTS_PREFIX + - Object.keys(records) - .sort() - .filter(key => !VARIABLES_TO_IGNORE.includes(key)) - .map(key => `${key}="${escapeValue(records[key])}"`) - .join('\n') + - '\n'; + const newEnv = Object.fromEntries( + Object.entries(records).filter( + ([key]) => !VARIABLES_TO_IGNORE.includes(key) + ) + ); + deltaString = buildDeltaString(oldEnv, newEnv); - await outputFile(fullPath, contents, 'utf8'); + const encrypt = existsSync(join(cwd, '.env.keys')); + + for (const [key, value] of Object.entries(newEnv)) { + try { + dotenvx.set(key, value ?? '', { path: fullPath, encrypt }); + } catch (error) { + throw new Error( + `Failed to set environment variable ${key}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } if (deltaString) { output.print('\n' + deltaString); @@ -225,11 +196,3 @@ export async function envPullCommandLogic( )}\n` ); } - -function escapeValue(value: string | undefined) { - return value - ? value - .replace(new RegExp('\n', 'g'), '\\n') // combine newlines (unix) into one line - .replace(new RegExp('\r', 'g'), '\\r') // combine newlines (windows) into one line - : ''; -} diff --git a/packages/cli/src/commands/pull/index.ts b/packages/cli/src/commands/pull/index.ts index 3c119d51744f..c43c3d991199 100644 --- a/packages/cli/src/commands/pull/index.ts +++ b/packages/cli/src/commands/pull/index.ts @@ -35,7 +35,6 @@ async function pullAllEnvFiles( await envPullCommandLogic( client, join('.vercel', environmentFile), - !!flags['--yes'], environment, link, flags['--git-branch'], diff --git a/packages/cli/src/util/env/diff-env-files.ts b/packages/cli/src/util/env/diff-env-files.ts index d0062f7a5619..98ee46dcb7e3 100644 --- a/packages/cli/src/util/env/diff-env-files.ts +++ b/packages/cli/src/util/env/diff-env-files.ts @@ -1,31 +1,5 @@ import type { Dictionary } from '@vercel/client'; -import { readFile } from 'fs-extra'; -import { parseEnv } from '../parse-env'; import chalk from 'chalk'; -import output from '../../output-manager'; - -export async function createEnvObject( - envPath: string -): Promise | undefined> { - // Originally authored by Tyler Waters under MIT License: https://github.com/tswaters/env-file-parser/blob/f17c009b39da599380e069ee72728d1cafdb56b8/lib/parse.js - // https://github.com/tswaters/env-file-parser/blob/f17c009b39da599380e069ee72728d1cafdb56b8/LICENSE - const envArr = (await readFile(envPath, 'utf-8')) - // remove double quotes - .replace(/"/g, '') - // split on new line - .split(/\r?\n|\r/) - // filter comments - .filter(line => /^[^#]/.test(line)) - // needs equal sign - .filter(line => /=/i.test(line)); - - const parsedEnv = parseEnv(envArr); - if (Object.keys(parsedEnv).length === 0) { - output.debug('Failed to parse env file.'); - return; - } - return parsedEnv; -} function findChanges( oldEnv: Dictionary, diff --git a/packages/cli/src/util/telemetry/commands/env/pull.ts b/packages/cli/src/util/telemetry/commands/env/pull.ts index 8c7ea6bb4daf..834fd4e06bde 100644 --- a/packages/cli/src/util/telemetry/commands/env/pull.ts +++ b/packages/cli/src/util/telemetry/commands/env/pull.ts @@ -38,10 +38,4 @@ export class EnvPullTelemetryClient }); } } - - trackCliFlagYes(yes: boolean | undefined) { - if (yes) { - this.trackCliFlag('yes'); - } - } } diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.env.local b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.env.local new file mode 100644 index 000000000000..91fa23d1d323 --- /dev/null +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.env.local @@ -0,0 +1,8 @@ +# This file contains environment variables +# Database configuration +DATABASE_URL=postgresql://localhost:5432/mydb + +# API keys +API_KEY=existing-key # inline comment here + +# End of file comment \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.gitignore b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.gitignore new file mode 100644 index 000000000000..ed3e274407a2 --- /dev/null +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.gitignore @@ -0,0 +1 @@ +!.vercel \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.vercel/project.json b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.vercel/project.json new file mode 100644 index 000000000000..fbcba3c93058 --- /dev/null +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.vercel/project.json @@ -0,0 +1,4 @@ +{ + "orgId": "team_dummy", + "projectId": "vercel-env-pull" +} \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.env.local b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.env.local new file mode 100644 index 000000000000..b6cf01b77d59 --- /dev/null +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.env.local @@ -0,0 +1,2 @@ +SPECIAL_FLAG=local-value-different-from-remote +EXISTING_LOCAL_ONLY=this-should-stay \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.gitignore b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.gitignore new file mode 100644 index 000000000000..ed3e274407a2 --- /dev/null +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.gitignore @@ -0,0 +1 @@ +!.vercel \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.vercel/project.json b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.vercel/project.json new file mode 100644 index 000000000000..fbcba3c93058 --- /dev/null +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.vercel/project.json @@ -0,0 +1,4 @@ +{ + "orgId": "team_dummy", + "projectId": "vercel-env-pull" +} \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.env.keys b/packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.env.keys new file mode 100644 index 000000000000..82f4f9719709 --- /dev/null +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.env.keys @@ -0,0 +1,5 @@ +#/------------------!DOTENV_PRIVATE_KEYS!-------------------/ +#/ private decryption keys. DO NOT commit to source control / + +# .env.local +DOTENV_PRIVATE_KEY="ac74c0deb4b49cbd7a98b3bcdb8c5ae15cf4d9cae0b51c73f8a306b9a419f5e8" \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.gitignore b/packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.gitignore new file mode 100644 index 000000000000..50c918f4cddd --- /dev/null +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.gitignore @@ -0,0 +1,2 @@ +!.vercel +!.env.keys diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.vercel/project.json b/packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.vercel/project.json new file mode 100644 index 000000000000..fbcba3c93058 --- /dev/null +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-with-encryption/.vercel/project.json @@ -0,0 +1,4 @@ +{ + "orgId": "team_dummy", + "projectId": "vercel-env-pull" +} \ No newline at end of file diff --git a/packages/cli/test/unit/commands/env/pull.test.ts b/packages/cli/test/unit/commands/env/pull.test.ts index bac055517d7f..084e0b2bbc04 100644 --- a/packages/cli/test/unit/commands/env/pull.test.ts +++ b/packages/cli/test/unit/commands/env/pull.test.ts @@ -38,7 +38,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull', '--yes'); + client.setArgv('env', 'pull'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -60,10 +60,6 @@ describe('env pull', () => { key: `subcommand:pull`, value: 'pull', }, - { - key: `flag:yes`, - value: 'TRUE', - }, ]); }); @@ -77,7 +73,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull', '--yes', '--environment', 'preview'); + client.setArgv('env', 'pull', '--environment', 'preview'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `preview` Environment Variables for' @@ -111,7 +107,6 @@ describe('env pull', () => { client.setArgv( 'env', 'pull', - '--yes', '--environment', 'preview', '--git-branch', @@ -137,7 +132,7 @@ describe('env pull', () => { ); const parsed = parse(rawDevEnv); - const keys = Object.keys(parsed); + const keys = Object.keys(parsed).sort(); expect(keys).toHaveLength(3); expect(keys[0]).toEqual('ANOTHER'); expect(keys[1]).toEqual('BRANCH_ENV_VAR'); @@ -148,10 +143,6 @@ describe('env pull', () => { key: `subcommand:pull`, value: 'pull', }, - { - key: `flag:yes`, - value: 'TRUE', - }, { key: `option:git-branch`, value: '[REDACTED]', @@ -173,7 +164,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull', 'other.env', '--yes'); + client.setArgv('env', 'pull', 'other.env'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -198,10 +189,6 @@ describe('env pull', () => { key: `argument:filename`, value: '[REDACTED]', }, - { - key: `flag:yes`, - value: 'TRUE', - }, ]); }); @@ -246,14 +233,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv( - 'env', - 'pull', - 'other.env', - '--yes', - '--environment', - 'production' - ); + client.setArgv('env', 'pull', 'other.env', '--environment', 'production'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `production` Environment Variables for' @@ -282,7 +262,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull', 'other.env', '--yes'); + client.setArgv('env', 'pull', 'other.env'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -332,7 +312,7 @@ describe('env pull', () => { await expect(addPromise).resolves.toEqual(0); - client.setArgv('env', 'pull', '--yes'); + client.setArgv('env', 'pull'); const pullPromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -361,7 +341,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull-delta-corrupt'); client.cwd = cwd; - client.setArgv('env', 'pull', '--yes'); + client.setArgv('env', 'pull'); const pullPromise = env(client); await expect(client.stderr).toOutput( 'Updated .env.local file and added it to .gitignore' @@ -378,7 +358,7 @@ describe('env pull', () => { name: 'env-pull-delta-no-changes', }); client.cwd = setupUnitFixture('vercel-env-pull-delta-no-changes'); - client.setArgv('env', 'pull', '--yes'); + client.setArgv('env', 'pull'); const pullPromise = env(client); await expect(client.stderr).toOutput('> No changes found.'); await expect(client.stderr).toOutput( @@ -414,12 +394,12 @@ describe('env pull', () => { ] ); - client.setArgv('env', 'pull', '--yes'); + client.setArgv('env', 'pull'); const pullPromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' ); - await expect(client.stderr).toOutput('No changes found.\n'); + await expect(client.stderr).toOutput('Changes:\n+ NEW_VAR (Updated)'); await expect(client.stderr).toOutput( 'Updated .env.local file and added it to .gitignore' ); @@ -458,7 +438,7 @@ describe('env pull', () => { ] ); - client.setArgv('env', 'pull', '.env.testquotes', '--yes'); + client.setArgv('env', 'pull', '.env.testquotes'); const pullPromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -488,7 +468,7 @@ describe('env pull', () => { 'utf8' ); client.cwd = cwd; - client.setArgv('env', 'pull', '--yes'); + client.setArgv('env', 'pull'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -539,7 +519,7 @@ describe('env pull', () => { ); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull', '--yes'); + client.setArgv('env', 'pull'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -553,6 +533,152 @@ describe('env pull', () => { expect(rawDevEnv.toString().includes('VERCEL_ANALYTICS_ID')).toBeFalsy(); }); + it('should preserve comments in existing .env files', async () => { + useUser(); + useTeams('team_dummy'); + useProject({ + ...defaultProject, + id: 'vercel-env-pull', + name: 'vercel-env-pull', + }); + const cwd = setupUnitFixture('vercel-env-pull-preserve-comments'); + client.cwd = cwd; + + // Read the original file to verify it has comments + const originalContent = await fs.readFile( + path.join(cwd, '.env.local'), + 'utf8' + ); + expect(originalContent).toContain( + '# This file contains environment variables' + ); + expect(originalContent).toContain('# Database configuration'); + expect(originalContent).toContain('# API keys'); + expect(originalContent).toContain('# inline comment here'); + expect(originalContent).toContain('# End of file comment'); + + client.setArgv('env', 'pull'); + const exitCodePromise = env(client); + await expect(client.stderr).toOutput( + 'Downloading `development` Environment Variables for' + ); + const exitCode = await exitCodePromise; + expect(exitCode, 'exit code for "env"').toEqual(0); + + // Read the file after pull and verify comments are preserved + const updatedContent = await fs.readFile( + path.join(cwd, '.env.local'), + 'utf8' + ); + expect(updatedContent).toContain( + '# This file contains environment variables' + ); + expect(updatedContent).toContain('# Database configuration'); + expect(updatedContent).toContain('# API keys'); + expect(updatedContent).toContain('# inline comment here'); + expect(updatedContent).toContain('# End of file comment'); + + // Should also contain the new SPECIAL_FLAG from the server + expect(updatedContent).toContain('SPECIAL_FLAG="1"'); + }); + + it('should preserve existing env values that match remote values', async () => { + useUser(); + useTeams('team_dummy'); + useProject({ + ...defaultProject, + id: 'vercel-env-pull', + name: 'vercel-env-pull', + }); + const cwd = setupUnitFixture('vercel-env-pull-preserve-values'); + client.cwd = cwd; + + // Read the original file + const originalContent = await fs.readFile( + path.join(cwd, '.env.local'), + 'utf8' + ); + expect(originalContent).toContain( + 'SPECIAL_FLAG=local-value-different-from-remote' + ); + expect(originalContent).toContain('EXISTING_LOCAL_ONLY=this-should-stay'); + + client.setArgv('env', 'pull'); + const exitCodePromise = env(client); + await expect(client.stderr).toOutput( + 'Downloading `development` Environment Variables for' + ); + const exitCode = await exitCodePromise; + expect(exitCode, 'exit code for "env"').toEqual(0); + + // Read the file after pull + const updatedContent = await fs.readFile( + path.join(cwd, '.env.local'), + 'utf8' + ); + + // The remote SPECIAL_FLAG=1 should override the local value + expect(updatedContent).toContain('SPECIAL_FLAG=1'); + expect(updatedContent).not.toContain( + 'SPECIAL_FLAG=local-value-different-from-remote' + ); + + // Local-only variables should be preserved + expect(updatedContent).toContain('EXISTING_LOCAL_ONLY=this-should-stay'); + }); + + it('should enable encryption when .env.keys file exists', async () => { + useUser(); + useTeams('team_dummy'); + useProject({ + ...defaultProject, + id: 'vercel-env-pull', + name: 'vercel-env-pull', + }); + const cwd = setupUnitFixture('vercel-env-pull-with-encryption'); + client.cwd = cwd; + client.setArgv('env', 'pull'); + const exitCodePromise = env(client); + await expect(client.stderr).toOutput( + 'Downloading `development` Environment Variables for' + ); + await expect(client.stderr).toOutput( + 'Created .env.local file and added it to .gitignore' + ); + const exitCode = await exitCodePromise; + expect(exitCode, 'exit code for "env"').toEqual(0); + + const rawDevEnv = await fs.readFile(path.join(cwd, '.env.local'), 'utf8'); + + // With .env.keys present, the SPECIAL_FLAG should be encrypted + // The exact format depends on dotenvx implementation, but it should be encrypted + expect(rawDevEnv).toContain('SPECIAL_FLAG="encrypted:'); + }); + + it('should use regular format when no .env.keys file exists', async () => { + useUser(); + useTeams('team_dummy'); + useProject({ + ...defaultProject, + id: 'vercel-env-pull', + name: 'vercel-env-pull', + }); + const cwd = setupUnitFixture('vercel-env-pull'); + client.cwd = cwd; + client.setArgv('env', 'pull'); + const exitCodePromise = env(client); + await expect(client.stderr).toOutput( + 'Downloading `development` Environment Variables for' + ); + const exitCode = await exitCodePromise; + expect(exitCode, 'exit code for "env"').toEqual(0); + + const rawDevEnv = await fs.readFile(path.join(cwd, '.env.local'), 'utf8'); + + // Without .env.keys, values should be stored in plain text + expect(rawDevEnv).toContain('SPECIAL_FLAG="1"'); + }); + describe('[filename]', () => { it('tracks filename argument', async () => { const project = 'vercel-env-pull'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b849352cd3e..78211d678bdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -333,6 +333,9 @@ importers: packages/cli: dependencies: + '@dotenvx/dotenvx': + specifier: 1.49.0 + version: 1.49.0 '@vercel/build-utils': specifier: 10.5.1 version: link:../build-utils @@ -3559,6 +3562,30 @@ packages: dependencies: '@jridgewell/trace-mapping': 0.3.9 + /@dotenvx/dotenvx@1.49.0: + resolution: {integrity: sha512-M1cyP6YstFQCjih54SAxCqHLMMi8QqV8tenpgGE48RTXWD7vfMYJiw/6xcCDpS2h28AcLpTsFCZA863Ge9yxzA==} + hasBin: true + dependencies: + commander: 11.1.0 + dotenv: 17.2.1 + eciesjs: 0.4.15 + execa: 5.1.1 + fdir: 6.4.4(picomatch@4.0.2) + ignore: 5.3.2 + object-treeify: 1.1.33 + picomatch: 4.0.2 + which: 4.0.0 + dev: false + + /@ecies/ciphers@0.2.4(@noble/ciphers@1.3.0): + resolution: {integrity: sha512-t+iX+Wf5nRKyNzk8dviW3Ikb/280+aEJAnw9YXvCp2tYGPSkMki+NRY+8aNLmVFv3eNtMdvViPNOPxS8SZNP+w==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + dependencies: + '@noble/ciphers': 1.3.0 + dev: false + /@edge-runtime/format@2.2.1: resolution: {integrity: sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==} engines: {node: '>=16'} @@ -4865,6 +4892,23 @@ packages: eslint-scope: 5.1.1 dev: true + /@noble/ciphers@1.3.0: + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + dev: false + + /@noble/curves@1.9.7: + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + dependencies: + '@noble/hashes': 1.8.0 + dev: false + + /@noble/hashes@1.8.0: + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -9387,6 +9431,11 @@ packages: delayed-stream: 1.0.0 dev: true + /commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + dev: false + /commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -9935,6 +9984,11 @@ packages: engines: {node: '>=12'} dev: true + /dotenv@17.2.1: + resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==} + engines: {node: '>=12'} + dev: false + /dotenv@4.0.0: resolution: {integrity: sha512-XcaMACOr3JMVcEv0Y/iUM2XaOsATRZ3U1In41/1jjK6vJZ2PZbQ1bzCG8uvaByfaBpl9gqc9QWJovpUGBXLLYQ==} engines: {node: '>=4.6.0'} @@ -9964,6 +10018,16 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + /eciesjs@0.4.15: + resolution: {integrity: sha512-r6kEJXDKecVOCj2nLMuXK/FCPeurW33+3JRpfXVbjLja3XUYFfD9I/JBreH6sUyzcm3G/YQboBjMla6poKeSdA==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + dependencies: + '@ecies/ciphers': 0.2.4(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + dev: false + /edge-runtime@2.5.9: resolution: {integrity: sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==} engines: {node: '>=16'} @@ -10599,7 +10663,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.24.0)(typescript@4.9.4) + '@typescript-eslint/parser': 5.62.0(eslint@8.24.0)(typescript@4.9.5) debug: 3.2.7 eslint: 8.24.0 eslint-import-resolver-node: 0.3.9 @@ -10630,7 +10694,7 @@ packages: optional: true dependencies: '@rtsao/scc': 1.1.0 - '@typescript-eslint/parser': 5.62.0(eslint@8.24.0)(typescript@4.9.4) + '@typescript-eslint/parser': 5.62.0(eslint@8.24.0)(typescript@4.9.5) array-includes: 3.1.8 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 @@ -10778,7 +10842,7 @@ packages: optional: true dependencies: eslint: 8.24.0 - eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@5.62.0)(eslint@8.24.0)(jest@29.5.0)(typescript@4.9.4) + eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@5.62.0)(eslint@8.24.0)(jest@29.5.0)(typescript@4.9.5) dev: true /eslint-plugin-react-hooks@4.6.2(eslint@8.24.0): @@ -11121,7 +11185,6 @@ packages: onetime: 5.1.2 signal-exit: 3.0.7 strip-final-newline: 2.0.0 - dev: true /execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} @@ -11340,7 +11403,6 @@ packages: optional: true dependencies: picomatch: 4.0.2 - dev: true /figures@1.7.0: resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==} @@ -11691,7 +11753,6 @@ packages: /get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - dev: true /get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} @@ -12089,7 +12150,6 @@ packages: /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - dev: true /human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} @@ -12137,7 +12197,6 @@ packages: /ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - dev: true /immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -12495,7 +12554,6 @@ packages: /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - dev: true /is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} @@ -12588,6 +12646,11 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + dev: false + /istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -13795,7 +13858,6 @@ packages: /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - dev: true /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} @@ -13885,7 +13947,6 @@ packages: /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - dev: true /mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} @@ -14257,7 +14318,6 @@ packages: engines: {node: '>=8'} dependencies: path-key: 3.1.1 - dev: true /npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} @@ -14290,6 +14350,11 @@ packages: engines: {node: '>= 0.4'} dev: true + /object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + dev: false + /object.assign@4.1.7: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} @@ -14370,7 +14435,6 @@ packages: engines: {node: '>=6'} dependencies: mimic-fn: 2.1.0 - dev: true /onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} @@ -15713,7 +15777,6 @@ packages: /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - dev: true /signal-exit@4.0.2: resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} @@ -16132,7 +16195,6 @@ packages: /strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - dev: true /strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} @@ -17931,6 +17993,14 @@ packages: isexe: 2.0.0 dev: true + /which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + dependencies: + isexe: 3.1.1 + dev: false + /why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} From 45908ca544b88d2896b3281ea6b8e2e13b492dbd Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Sun, 24 Aug 2025 10:05:52 +0200 Subject: [PATCH 02/12] Add backup Signed-off-by: Alexis Rico --- packages/cli/src/commands/env/pull.ts | 28 ++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/env/pull.ts b/packages/cli/src/commands/env/pull.ts index 91cd4dee874b..8c649de84b4e 100644 --- a/packages/cli/src/commands/env/pull.ts +++ b/packages/cli/src/commands/env/pull.ts @@ -160,16 +160,38 @@ export async function envPullCommandLogic( const encrypt = existsSync(join(cwd, '.env.keys')); - for (const [key, value] of Object.entries(newEnv)) { + let backupContent: string | null = null; + if (exists) { try { - dotenvx.set(key, value ?? '', { path: fullPath, encrypt }); + backupContent = readFileSync(fullPath, 'utf8'); } catch (error) { throw new Error( - `Failed to set environment variable ${key}: ${error instanceof Error ? error.message : String(error)}` + `Failed to read existing env file for backup at ${fullPath}: ${error instanceof Error ? error.message : String(error)}` ); } } + // Attempt to set all environment variables atomically + try { + for (const [key, value] of Object.entries(newEnv)) { + dotenvx.set(key, value ?? '', { path: fullPath, encrypt }); + } + } catch (error) { + // Restore backup on any failure to ensure atomic operation + if (backupContent !== null) { + try { + await outputFile(fullPath, backupContent, 'utf8'); + } catch (restoreError) { + throw new Error( + `Failed to set environment variable and unable to restore backup: ${error instanceof Error ? error.message : String(error)}. Restore error: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}` + ); + } + } + throw new Error( + `Failed to set environment variable: ${error instanceof Error ? error.message : String(error)}` + ); + } + if (deltaString) { output.print('\n' + deltaString); } else if (oldEnv && exists) { From da3c1a69b68c055982750400f45a73c96b24f758 Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 00:39:15 +0200 Subject: [PATCH 03/12] Add back --yes Signed-off-by: Alexis Rico --- packages/cli/src/commands/env/command.ts | 5 + packages/cli/src/commands/env/pull.ts | 114 ++++++++++-------- packages/cli/src/commands/pull/index.ts | 1 + packages/cli/src/util/env/diff-env-files.ts | 59 +++++++++ .../src/util/telemetry/commands/env/pull.ts | 6 + .../vercel-env-pull-delta-quotes/.env.local | 2 +- .../cli/test/unit/commands/env/pull.test.ts | 50 +++++--- 7 files changed, 173 insertions(+), 64 deletions(-) diff --git a/packages/cli/src/commands/env/command.ts b/packages/cli/src/commands/env/command.ts index 3eaa3cc4e9c5..990daf15a9a7 100644 --- a/packages/cli/src/commands/env/command.ts +++ b/packages/cli/src/commands/env/command.ts @@ -166,6 +166,11 @@ export const pullSubcommand = { argument: 'NAME', deprecated: false, }, + { + ...yesOption, + description: + 'Skip the confirmation prompt when removing an environment variable', + }, ], examples: [ { diff --git a/packages/cli/src/commands/env/pull.ts b/packages/cli/src/commands/env/pull.ts index 8c649de84b4e..c0ff0e878af6 100644 --- a/packages/cli/src/commands/env/pull.ts +++ b/packages/cli/src/commands/env/pull.ts @@ -1,13 +1,22 @@ -import * as dotenvx from '@dotenvx/dotenvx'; import type { ProjectLinked } from '@vercel-internals/types'; +import { isErrnoException } from '@vercel/error-utils'; import chalk from 'chalk'; -import { existsSync, readFileSync } from 'fs'; -import { outputFile } from 'fs-extra'; -import { join, resolve } from 'path'; +import { + closeSync, + existsSync, + openSync, + outputFile, + readSync, +} from 'fs-extra'; +import { resolve } from 'path'; import output from '../../output-manager'; import type Client from '../../util/client'; import { emoji, prependEmoji } from '../../util/emoji'; -import { buildDeltaString } from '../../util/env/diff-env-files'; +import { + buildDeltaString, + createEnvObject, + updateEnvFile, +} from '../../util/env/diff-env-files'; import { type EnvRecordsSource, pullEnvRecords, @@ -16,6 +25,7 @@ import { printError } from '../../util/error'; import { parseArguments } from '../../util/get-args'; import { getFlagsSpecification } from '../../util/get-flags-specification'; import { addToGitIgnore } from '../../util/link/add-to-gitignore'; +import param from '../../util/output/param'; import stamp from '../../util/output/stamp'; import parseTarget from '../../util/parse-target'; import { getCommandName } from '../../util/pkg-name'; @@ -26,6 +36,27 @@ import { pullSubcommand } from './command'; const CONTENTS_PREFIX = '# Created by Vercel CLI\n'; +function readHeadSync(path: string, length: number) { + const buffer = Buffer.alloc(length); + const fd = openSync(path, 'r'); + try { + readSync(fd, buffer, 0, buffer.length, null); + } finally { + closeSync(fd); + } + return buffer.toString(); +} + +function tryReadHeadSync(path: string, length: number) { + try { + return readHeadSync(path, length); + } catch (err: unknown) { + if (!isErrnoException(err) || err.code !== 'ENOENT') { + throw err; + } + } +} + const VARIABLES_TO_IGNORE = [ 'VERCEL_ANALYTICS_ID', 'VERCEL_SPEED_INSIGHTS_ID', @@ -60,9 +91,11 @@ export default async function pull(client: Client, argv: string[]) { // handle relative or absolute filename const [rawFilename] = args; const filename = rawFilename || '.env.local'; + const skipConfirmation = opts['--yes']; const gitBranch = opts['--git-branch']; telemetryClient.trackCliArgumentFilename(args[0]); + telemetryClient.trackCliFlagYes(skipConfirmation); telemetryClient.trackCliOptionGitBranch(gitBranch); telemetryClient.trackCliOptionEnvironment(opts['--environment']); @@ -89,6 +122,7 @@ export default async function pull(client: Client, argv: string[]) { await envPullCommandLogic( client, filename, + !!skipConfirmation, environment, link, gitBranch, @@ -102,6 +136,7 @@ export default async function pull(client: Client, argv: string[]) { export async function envPullCommandLogic( client: Client, filename: string, + skipConfirmation: boolean, environment: string, link: ProjectLinked, gitBranch: string | undefined, @@ -109,8 +144,23 @@ export async function envPullCommandLogic( source: EnvRecordsSource ) { const fullPath = resolve(cwd, filename); + const head = tryReadHeadSync(fullPath, CONTENTS_PREFIX.length); const exists = existsSync(fullPath); + if (head === CONTENTS_PREFIX) { + output.log(`Overwriting existing ${chalk.bold(filename)} file`); + } else if ( + exists && + !skipConfirmation && + !(await client.input.confirm( + `Found existing file ${param(filename)}. Do you want to update?`, + false + )) + ) { + output.log('Canceled'); + return; + } + const projectSlugLink = formatProject(link.org.slug, link.project.name); output.log( @@ -128,14 +178,21 @@ export async function envPullCommandLogic( gitBranch, }) ).env; + const newEnv = Object.fromEntries( + Object.entries(records).filter( + ([key]) => !VARIABLES_TO_IGNORE.includes(key) + ) + ); let deltaString = ''; - let oldEnv: Record = {}; + let oldEnv; if (exists) { try { - const fileContent = readFileSync(fullPath, 'utf8'); - oldEnv = dotenvx.parse(fileContent, { processEnv: {} }); + oldEnv = await createEnvObject(fullPath); + if (oldEnv) { + deltaString = buildDeltaString(oldEnv, newEnv); + } } catch (error) { throw new Error( `Failed to parse existing env file at ${fullPath}: ${error instanceof Error ? error.message : String(error)}` @@ -151,46 +208,7 @@ export async function envPullCommandLogic( } } - const newEnv = Object.fromEntries( - Object.entries(records).filter( - ([key]) => !VARIABLES_TO_IGNORE.includes(key) - ) - ); - deltaString = buildDeltaString(oldEnv, newEnv); - - const encrypt = existsSync(join(cwd, '.env.keys')); - - let backupContent: string | null = null; - if (exists) { - try { - backupContent = readFileSync(fullPath, 'utf8'); - } catch (error) { - throw new Error( - `Failed to read existing env file for backup at ${fullPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - // Attempt to set all environment variables atomically - try { - for (const [key, value] of Object.entries(newEnv)) { - dotenvx.set(key, value ?? '', { path: fullPath, encrypt }); - } - } catch (error) { - // Restore backup on any failure to ensure atomic operation - if (backupContent !== null) { - try { - await outputFile(fullPath, backupContent, 'utf8'); - } catch (restoreError) { - throw new Error( - `Failed to set environment variable and unable to restore backup: ${error instanceof Error ? error.message : String(error)}. Restore error: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}` - ); - } - } - throw new Error( - `Failed to set environment variable: ${error instanceof Error ? error.message : String(error)}` - ); - } + await updateEnvFile(fullPath, newEnv); if (deltaString) { output.print('\n' + deltaString); diff --git a/packages/cli/src/commands/pull/index.ts b/packages/cli/src/commands/pull/index.ts index c43c3d991199..3c119d51744f 100644 --- a/packages/cli/src/commands/pull/index.ts +++ b/packages/cli/src/commands/pull/index.ts @@ -35,6 +35,7 @@ async function pullAllEnvFiles( await envPullCommandLogic( client, join('.vercel', environmentFile), + !!flags['--yes'], environment, link, flags['--git-branch'], diff --git a/packages/cli/src/util/env/diff-env-files.ts b/packages/cli/src/util/env/diff-env-files.ts index 98ee46dcb7e3..5d2f10dbbae1 100644 --- a/packages/cli/src/util/env/diff-env-files.ts +++ b/packages/cli/src/util/env/diff-env-files.ts @@ -1,5 +1,64 @@ +import * as dotenvx from '@dotenvx/dotenvx'; import type { Dictionary } from '@vercel/client'; import chalk from 'chalk'; +import { existsSync, outputFile, readFile } from 'fs-extra'; +import { dirname, join } from 'path'; + +export async function createEnvObject( + envPath: string +): Promise | undefined> { + const content = await readFile(envPath, 'utf-8'); + const privateKey = await getEncryptionKey(envPath); + + return dotenvx.parse(content, { processEnv: {}, privateKey }); +} + +export async function updateEnvFile( + envPath: string, + updates: Dictionary +): Promise { + let backupContent: string | null = null; + try { + backupContent = await readFile(envPath, 'utf8'); + } catch (error) { + // Continue without backup if file does not exist + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const privateKey = await getEncryptionKey(envPath); + + try { + for (const [key, value] of Object.entries(updates)) { + dotenvx.set(key, value ?? '', { path: envPath, encrypt: !!privateKey }); + } + } catch (error) { + // Restore backup on any failure to ensure atomic operation + if (backupContent !== null) { + try { + await outputFile(envPath, backupContent, 'utf8'); + } catch (restoreError) { + throw new Error( + `Failed to set environment variable and unable to restore backup: ${error instanceof Error ? error.message : String(error)}. Restore error: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}` + ); + } + } + throw new Error( + `Failed to set environment variable: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +async function getEncryptionKey(envPath: string): Promise { + const keysPath = join(dirname(envPath), '.env.keys'); + if (!existsSync(keysPath)) { + return undefined; + } + + const keys = await readFile(keysPath, 'utf8'); + return dotenvx.parse(keys, { processEnv: {} })['PRIVATE_KEY']; +} function findChanges( oldEnv: Dictionary, diff --git a/packages/cli/src/util/telemetry/commands/env/pull.ts b/packages/cli/src/util/telemetry/commands/env/pull.ts index 834fd4e06bde..8c7ea6bb4daf 100644 --- a/packages/cli/src/util/telemetry/commands/env/pull.ts +++ b/packages/cli/src/util/telemetry/commands/env/pull.ts @@ -38,4 +38,10 @@ export class EnvPullTelemetryClient }); } } + + trackCliFlagYes(yes: boolean | undefined) { + if (yes) { + this.trackCliFlag('yes'); + } + } } diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-delta-quotes/.env.local b/packages/cli/test/fixtures/unit/vercel-env-pull-delta-quotes/.env.local index 09fc623e4077..e1ba4ca8b913 100644 --- a/packages/cli/test/fixtures/unit/vercel-env-pull-delta-quotes/.env.local +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-delta-quotes/.env.local @@ -1,2 +1,2 @@ -NEW_VAR=testvalue +NEW_VAR=""testvalue"" SPECIAL_FLAG=1 diff --git a/packages/cli/test/unit/commands/env/pull.test.ts b/packages/cli/test/unit/commands/env/pull.test.ts index 084e0b2bbc04..9819ba90d0d1 100644 --- a/packages/cli/test/unit/commands/env/pull.test.ts +++ b/packages/cli/test/unit/commands/env/pull.test.ts @@ -38,7 +38,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull'); + client.setArgv('env', 'pull', '--yes'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -60,6 +60,10 @@ describe('env pull', () => { key: `subcommand:pull`, value: 'pull', }, + { + key: `flag:yes`, + value: 'TRUE', + }, ]); }); @@ -73,7 +77,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull', '--environment', 'preview'); + client.setArgv('env', 'pull', '--yes', '--environment', 'preview'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `preview` Environment Variables for' @@ -107,6 +111,7 @@ describe('env pull', () => { client.setArgv( 'env', 'pull', + '--yes', '--environment', 'preview', '--git-branch', @@ -143,6 +148,10 @@ describe('env pull', () => { key: `subcommand:pull`, value: 'pull', }, + { + key: `flag:yes`, + value: 'TRUE', + }, { key: `option:git-branch`, value: '[REDACTED]', @@ -164,7 +173,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull', 'other.env'); + client.setArgv('env', 'pull', 'other.env', '--yes'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -189,6 +198,10 @@ describe('env pull', () => { key: `argument:filename`, value: '[REDACTED]', }, + { + key: `flag:yes`, + value: 'TRUE', + }, ]); }); @@ -233,7 +246,14 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull', 'other.env', '--environment', 'production'); + client.setArgv( + 'env', + 'pull', + 'other.env', + '--yes', + '--environment', + 'production' + ); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `production` Environment Variables for' @@ -262,7 +282,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull', 'other.env'); + client.setArgv('env', 'pull', 'other.env', '--yes'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -312,7 +332,7 @@ describe('env pull', () => { await expect(addPromise).resolves.toEqual(0); - client.setArgv('env', 'pull'); + client.setArgv('env', 'pull', '--yes'); const pullPromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -341,7 +361,7 @@ describe('env pull', () => { }); const cwd = setupUnitFixture('vercel-env-pull-delta-corrupt'); client.cwd = cwd; - client.setArgv('env', 'pull'); + client.setArgv('env', 'pull', '--yes'); const pullPromise = env(client); await expect(client.stderr).toOutput( 'Updated .env.local file and added it to .gitignore' @@ -358,7 +378,7 @@ describe('env pull', () => { name: 'env-pull-delta-no-changes', }); client.cwd = setupUnitFixture('vercel-env-pull-delta-no-changes'); - client.setArgv('env', 'pull'); + client.setArgv('env', 'pull', '--yes'); const pullPromise = env(client); await expect(client.stderr).toOutput('> No changes found.'); await expect(client.stderr).toOutput( @@ -394,12 +414,12 @@ describe('env pull', () => { ] ); - client.setArgv('env', 'pull'); + client.setArgv('env', 'pull', '--yes'); const pullPromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' ); - await expect(client.stderr).toOutput('Changes:\n+ NEW_VAR (Updated)'); + await expect(client.stderr).toOutput('No changes found.\n'); await expect(client.stderr).toOutput( 'Updated .env.local file and added it to .gitignore' ); @@ -438,7 +458,7 @@ describe('env pull', () => { ] ); - client.setArgv('env', 'pull', '.env.testquotes'); + client.setArgv('env', 'pull', '.env.testquotes', '--yes'); const pullPromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -468,7 +488,7 @@ describe('env pull', () => { 'utf8' ); client.cwd = cwd; - client.setArgv('env', 'pull'); + client.setArgv('env', 'pull', '--yes'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -519,7 +539,7 @@ describe('env pull', () => { ); const cwd = setupUnitFixture('vercel-env-pull'); client.cwd = cwd; - client.setArgv('env', 'pull'); + client.setArgv('env', 'pull', '--yes'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -557,7 +577,7 @@ describe('env pull', () => { expect(originalContent).toContain('# inline comment here'); expect(originalContent).toContain('# End of file comment'); - client.setArgv('env', 'pull'); + client.setArgv('env', 'pull', '--yes'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' @@ -603,7 +623,7 @@ describe('env pull', () => { ); expect(originalContent).toContain('EXISTING_LOCAL_ONLY=this-should-stay'); - client.setArgv('env', 'pull'); + client.setArgv('env', 'pull', '--yes'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' From 01e12bec6e064ce47173e582d6827de8505614da Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 00:44:33 +0200 Subject: [PATCH 04/12] Fix test Signed-off-by: Alexis Rico --- packages/cli/src/util/env/diff-env-files.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/util/env/diff-env-files.ts b/packages/cli/src/util/env/diff-env-files.ts index 5d2f10dbbae1..fda74455f16e 100644 --- a/packages/cli/src/util/env/diff-env-files.ts +++ b/packages/cli/src/util/env/diff-env-files.ts @@ -56,8 +56,10 @@ async function getEncryptionKey(envPath: string): Promise { return undefined; } - const keys = await readFile(keysPath, 'utf8'); - return dotenvx.parse(keys, { processEnv: {} })['PRIVATE_KEY']; + const keys = dotenvx.parse(await readFile(keysPath, 'utf8'), { + processEnv: {}, + }); + return keys.DOTENV_PRIVATE_KEY; } function findChanges( From 8f6fd4cbff03e16bfb888350960a3fdc8aad36f1 Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 00:46:42 +0200 Subject: [PATCH 05/12] Update file Signed-off-by: Alexis Rico --- packages/cli/src/commands/env/pull.ts | 2 +- packages/cli/src/util/blob/token.ts | 2 +- .../{diff-env-files.ts => local-env-files.ts} | 1 + .../cli/test/unit/commands/env/pull.test.ts | 34 +++++++++++++++++++ .../cli/test/unit/util/blob/token.test.ts | 4 +-- 5 files changed, 39 insertions(+), 4 deletions(-) rename packages/cli/src/util/env/{diff-env-files.ts => local-env-files.ts} (99%) diff --git a/packages/cli/src/commands/env/pull.ts b/packages/cli/src/commands/env/pull.ts index c0ff0e878af6..829287c58d6c 100644 --- a/packages/cli/src/commands/env/pull.ts +++ b/packages/cli/src/commands/env/pull.ts @@ -16,7 +16,7 @@ import { buildDeltaString, createEnvObject, updateEnvFile, -} from '../../util/env/diff-env-files'; +} from '../../util/env/local-env-files'; import { type EnvRecordsSource, pullEnvRecords, diff --git a/packages/cli/src/util/blob/token.ts b/packages/cli/src/util/blob/token.ts index aa7c6e4e0a37..e1e515809de6 100644 --- a/packages/cli/src/util/blob/token.ts +++ b/packages/cli/src/util/blob/token.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path'; -import { createEnvObject } from '../env/diff-env-files'; +import { createEnvObject } from '../env/local-env-files'; import type Client from '../client'; import { getFlagsSpecification } from '../get-flags-specification'; diff --git a/packages/cli/src/util/env/diff-env-files.ts b/packages/cli/src/util/env/local-env-files.ts similarity index 99% rename from packages/cli/src/util/env/diff-env-files.ts rename to packages/cli/src/util/env/local-env-files.ts index fda74455f16e..d11173bb010f 100644 --- a/packages/cli/src/util/env/diff-env-files.ts +++ b/packages/cli/src/util/env/local-env-files.ts @@ -59,6 +59,7 @@ async function getEncryptionKey(envPath: string): Promise { const keys = dotenvx.parse(await readFile(keysPath, 'utf8'), { processEnv: {}, }); + return keys.DOTENV_PRIVATE_KEY; } diff --git a/packages/cli/test/unit/commands/env/pull.test.ts b/packages/cli/test/unit/commands/env/pull.test.ts index 9819ba90d0d1..bdcda545801c 100644 --- a/packages/cli/test/unit/commands/env/pull.test.ts +++ b/packages/cli/test/unit/commands/env/pull.test.ts @@ -699,6 +699,40 @@ describe('env pull', () => { expect(rawDevEnv).toContain('SPECIAL_FLAG="1"'); }); + it('should decrypt existing encrypted values when .env.keys is present', async () => { + useUser(); + useTeams('team_dummy'); + useProject({ + ...defaultProject, + id: 'vercel-env-pull', + name: 'vercel-env-pull', + }); + const cwd = setupUnitFixture('vercel-env-pull-with-encryption'); + client.cwd = cwd; + + // First, create an existing encrypted .env.local file + const envPath = path.join(cwd, '.env.local'); + await fs.writeFile( + envPath, + '# Created by Vercel CLI\nEXISTING_VAR="encrypted:123456"\n', + 'utf8' + ); + + client.setArgv('env', 'pull'); + const exitCodePromise = env(client); + await expect(client.stderr).toOutput( + 'Downloading `development` Environment Variables for' + ); + const exitCode = await exitCodePromise; + expect(exitCode, 'exit code for "env"').toEqual(0); + + const rawDevEnv = await fs.readFile(envPath, 'utf8'); + // Should contain the new encrypted value + expect(rawDevEnv).toContain('SPECIAL_FLAG="encrypted:'); + // Should preserve the Created by Vercel CLI header + expect(rawDevEnv).toContain('# Created by Vercel CLI'); + }); + describe('[filename]', () => { it('tracks filename argument', async () => { const project = 'vercel-env-pull'; diff --git a/packages/cli/test/unit/util/blob/token.test.ts b/packages/cli/test/unit/util/blob/token.test.ts index 7d2059aba1e3..4b3392872516 100644 --- a/packages/cli/test/unit/util/blob/token.test.ts +++ b/packages/cli/test/unit/util/blob/token.test.ts @@ -1,10 +1,10 @@ import { describe, beforeEach, expect, it, vi, afterEach } from 'vitest'; import { client } from '../../../mocks/client'; import { getBlobRWToken } from '../../../../src/util/blob/token'; -import * as envDiffModule from '../../../../src/util/env/diff-env-files'; +import * as envDiffModule from '../../../../src/util/env/local-env-files'; // Mock dependencies -vi.mock('../../../../src/util/env/diff-env-files'); +vi.mock('../../../../src/util/env/local-env-files'); const mockedCreateEnvObject = vi.mocked(envDiffModule.createEnvObject); From 7f84a8acc8a92b6e6fb05f1f5b44140712287a93 Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 00:48:34 +0200 Subject: [PATCH 06/12] Revert rename Signed-off-by: Alexis Rico --- packages/cli/src/commands/env/pull.ts | 15 ++++----------- packages/cli/src/util/blob/token.ts | 2 +- .../env/{local-env-files.ts => diff-env-files.ts} | 0 packages/cli/test/unit/util/blob/token.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 14 deletions(-) rename packages/cli/src/util/env/{local-env-files.ts => diff-env-files.ts} (100%) diff --git a/packages/cli/src/commands/env/pull.ts b/packages/cli/src/commands/env/pull.ts index 829287c58d6c..58a54b3b43ee 100644 --- a/packages/cli/src/commands/env/pull.ts +++ b/packages/cli/src/commands/env/pull.ts @@ -16,7 +16,7 @@ import { buildDeltaString, createEnvObject, updateEnvFile, -} from '../../util/env/local-env-files'; +} from '../../util/env/diff-env-files'; import { type EnvRecordsSource, pullEnvRecords, @@ -186,17 +186,10 @@ export async function envPullCommandLogic( let deltaString = ''; let oldEnv; - if (exists) { - try { - oldEnv = await createEnvObject(fullPath); - if (oldEnv) { - deltaString = buildDeltaString(oldEnv, newEnv); - } - } catch (error) { - throw new Error( - `Failed to parse existing env file at ${fullPath}: ${error instanceof Error ? error.message : String(error)}` - ); + oldEnv = await createEnvObject(fullPath); + if (oldEnv) { + deltaString = buildDeltaString(oldEnv, newEnv); } } else { try { diff --git a/packages/cli/src/util/blob/token.ts b/packages/cli/src/util/blob/token.ts index e1e515809de6..aa7c6e4e0a37 100644 --- a/packages/cli/src/util/blob/token.ts +++ b/packages/cli/src/util/blob/token.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path'; -import { createEnvObject } from '../env/local-env-files'; +import { createEnvObject } from '../env/diff-env-files'; import type Client from '../client'; import { getFlagsSpecification } from '../get-flags-specification'; diff --git a/packages/cli/src/util/env/local-env-files.ts b/packages/cli/src/util/env/diff-env-files.ts similarity index 100% rename from packages/cli/src/util/env/local-env-files.ts rename to packages/cli/src/util/env/diff-env-files.ts diff --git a/packages/cli/test/unit/util/blob/token.test.ts b/packages/cli/test/unit/util/blob/token.test.ts index 4b3392872516..7d2059aba1e3 100644 --- a/packages/cli/test/unit/util/blob/token.test.ts +++ b/packages/cli/test/unit/util/blob/token.test.ts @@ -1,10 +1,10 @@ import { describe, beforeEach, expect, it, vi, afterEach } from 'vitest'; import { client } from '../../../mocks/client'; import { getBlobRWToken } from '../../../../src/util/blob/token'; -import * as envDiffModule from '../../../../src/util/env/local-env-files'; +import * as envDiffModule from '../../../../src/util/env/diff-env-files'; // Mock dependencies -vi.mock('../../../../src/util/env/local-env-files'); +vi.mock('../../../../src/util/env/diff-env-files'); const mockedCreateEnvObject = vi.mocked(envDiffModule.createEnvObject); From 6ad7a7b1854b71341baec207f23e5c43892b983c Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 00:54:04 +0200 Subject: [PATCH 07/12] Better backup Signed-off-by: Alexis Rico --- packages/cli/src/util/env/diff-env-files.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/util/env/diff-env-files.ts b/packages/cli/src/util/env/diff-env-files.ts index d11173bb010f..89c8e5292eb4 100644 --- a/packages/cli/src/util/env/diff-env-files.ts +++ b/packages/cli/src/util/env/diff-env-files.ts @@ -18,13 +18,15 @@ export async function updateEnvFile( updates: Dictionary ): Promise { let backupContent: string | null = null; + try { - backupContent = await readFile(envPath, 'utf8'); - } catch (error) { - // Continue without backup if file does not exist - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; + if (existsSync(envPath)) { + backupContent = await readFile(envPath, 'utf8'); } + } catch (error) { + throw new Error( + `Failed to backup existing file: ${error instanceof Error ? error.message : String(error)}` + ); } const privateKey = await getEncryptionKey(envPath); From 964d19dde52ab79fc96ff0a04febad035692de72 Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 01:01:09 +0200 Subject: [PATCH 08/12] Update quote issue Signed-off-by: Alexis Rico --- .../test/fixtures/unit/vercel-env-pull-delta-quotes/.env.local | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-delta-quotes/.env.local b/packages/cli/test/fixtures/unit/vercel-env-pull-delta-quotes/.env.local index e1ba4ca8b913..319a082418f6 100644 --- a/packages/cli/test/fixtures/unit/vercel-env-pull-delta-quotes/.env.local +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-delta-quotes/.env.local @@ -1,2 +1,2 @@ -NEW_VAR=""testvalue"" +NEW_VAR='"testvalue"' SPECIAL_FLAG=1 From 999a2695752c007ec3fd5e1f68da0b1305a7ecb9 Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 01:15:20 +0200 Subject: [PATCH 09/12] Clean-up fixtures Signed-off-by: Alexis Rico --- packages/cli/src/commands/env/pull.ts | 2 +- .../.env.local | 2 + .../.gitignore | 0 .../.vercel/project.json | 0 .../.env.local | 2 - .../.gitignore | 1 - .../.vercel/project.json | 4 -- .../cli/test/unit/commands/env/pull.test.ts | 52 ++++--------------- 8 files changed, 13 insertions(+), 50 deletions(-) rename packages/cli/test/fixtures/unit/{vercel-env-pull-preserve-comments => vercel-env-pull-preserve-file}/.env.local (69%) rename packages/cli/test/fixtures/unit/{vercel-env-pull-preserve-comments => vercel-env-pull-preserve-file}/.gitignore (100%) rename packages/cli/test/fixtures/unit/{vercel-env-pull-preserve-comments => vercel-env-pull-preserve-file}/.vercel/project.json (100%) delete mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.env.local delete mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.gitignore delete mode 100644 packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.vercel/project.json diff --git a/packages/cli/src/commands/env/pull.ts b/packages/cli/src/commands/env/pull.ts index 58a54b3b43ee..7efeb28d5a9c 100644 --- a/packages/cli/src/commands/env/pull.ts +++ b/packages/cli/src/commands/env/pull.ts @@ -189,7 +189,7 @@ export async function envPullCommandLogic( if (exists) { oldEnv = await createEnvObject(fullPath); if (oldEnv) { - deltaString = buildDeltaString(oldEnv, newEnv); + deltaString = buildDeltaString(oldEnv, { ...oldEnv, ...newEnv }); } } else { try { diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.env.local b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.env.local similarity index 69% rename from packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.env.local rename to packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.env.local index 91fa23d1d323..1af7ab4f35ca 100644 --- a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.env.local +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.env.local @@ -4,5 +4,7 @@ DATABASE_URL=postgresql://localhost:5432/mydb # API keys API_KEY=existing-key # inline comment here +SPECIAL_FLAG=local-value-different-from-remote +EXISTING_LOCAL_ONLY=this-should-stay # End of file comment \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.gitignore b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.gitignore similarity index 100% rename from packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.gitignore rename to packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.gitignore diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.vercel/project.json b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.vercel/project.json similarity index 100% rename from packages/cli/test/fixtures/unit/vercel-env-pull-preserve-comments/.vercel/project.json rename to packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.vercel/project.json diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.env.local b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.env.local deleted file mode 100644 index b6cf01b77d59..000000000000 --- a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.env.local +++ /dev/null @@ -1,2 +0,0 @@ -SPECIAL_FLAG=local-value-different-from-remote -EXISTING_LOCAL_ONLY=this-should-stay \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.gitignore b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.gitignore deleted file mode 100644 index ed3e274407a2..000000000000 --- a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!.vercel \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.vercel/project.json b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.vercel/project.json deleted file mode 100644 index fbcba3c93058..000000000000 --- a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-values/.vercel/project.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "orgId": "team_dummy", - "projectId": "vercel-env-pull" -} \ No newline at end of file diff --git a/packages/cli/test/unit/commands/env/pull.test.ts b/packages/cli/test/unit/commands/env/pull.test.ts index bdcda545801c..bca480b8f632 100644 --- a/packages/cli/test/unit/commands/env/pull.test.ts +++ b/packages/cli/test/unit/commands/env/pull.test.ts @@ -553,7 +553,7 @@ describe('env pull', () => { expect(rawDevEnv.toString().includes('VERCEL_ANALYTICS_ID')).toBeFalsy(); }); - it('should preserve comments in existing .env files', async () => { + it('should preserve comments and local values when pulling env vars', async () => { useUser(); useTeams('team_dummy'); useProject({ @@ -561,10 +561,10 @@ describe('env pull', () => { id: 'vercel-env-pull', name: 'vercel-env-pull', }); - const cwd = setupUnitFixture('vercel-env-pull-preserve-comments'); + const cwd = setupUnitFixture('vercel-env-pull-preserve-file'); client.cwd = cwd; - // Read the original file to verify it has comments + // Read the original file to verify it has comments and local values const originalContent = await fs.readFile( path.join(cwd, '.env.local'), 'utf8' @@ -576,12 +576,19 @@ describe('env pull', () => { expect(originalContent).toContain('# API keys'); expect(originalContent).toContain('# inline comment here'); expect(originalContent).toContain('# End of file comment'); + expect(originalContent).toContain( + 'SPECIAL_FLAG=local-value-different-from-remote' + ); + expect(originalContent).toContain('EXISTING_LOCAL_ONLY=this-should-stay'); client.setArgv('env', 'pull', '--yes'); const exitCodePromise = env(client); await expect(client.stderr).toOutput( 'Downloading `development` Environment Variables for' ); + await expect(client.stderr).toOutput( + 'Changes:\n+ SPECIAL_FLAG (Updated)\n\nUpdated .env.local file' + ); const exitCode = await exitCodePromise; expect(exitCode, 'exit code for "env"').toEqual(0); @@ -598,45 +605,6 @@ describe('env pull', () => { expect(updatedContent).toContain('# inline comment here'); expect(updatedContent).toContain('# End of file comment'); - // Should also contain the new SPECIAL_FLAG from the server - expect(updatedContent).toContain('SPECIAL_FLAG="1"'); - }); - - it('should preserve existing env values that match remote values', async () => { - useUser(); - useTeams('team_dummy'); - useProject({ - ...defaultProject, - id: 'vercel-env-pull', - name: 'vercel-env-pull', - }); - const cwd = setupUnitFixture('vercel-env-pull-preserve-values'); - client.cwd = cwd; - - // Read the original file - const originalContent = await fs.readFile( - path.join(cwd, '.env.local'), - 'utf8' - ); - expect(originalContent).toContain( - 'SPECIAL_FLAG=local-value-different-from-remote' - ); - expect(originalContent).toContain('EXISTING_LOCAL_ONLY=this-should-stay'); - - client.setArgv('env', 'pull', '--yes'); - const exitCodePromise = env(client); - await expect(client.stderr).toOutput( - 'Downloading `development` Environment Variables for' - ); - const exitCode = await exitCodePromise; - expect(exitCode, 'exit code for "env"').toEqual(0); - - // Read the file after pull - const updatedContent = await fs.readFile( - path.join(cwd, '.env.local'), - 'utf8' - ); - // The remote SPECIAL_FLAG=1 should override the local value expect(updatedContent).toContain('SPECIAL_FLAG=1'); expect(updatedContent).not.toContain( From d75ebaef649e418427316a021fd71f90b6b3f053 Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 01:30:00 +0200 Subject: [PATCH 10/12] Fix tests Signed-off-by: Alexis Rico --- .../fixtures/unit/vercel-env-pull-preserve-file/.env.local | 2 +- packages/cli/test/unit/commands/env/pull.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.env.local b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.env.local index 1af7ab4f35ca..9e5753640722 100644 --- a/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.env.local +++ b/packages/cli/test/fixtures/unit/vercel-env-pull-preserve-file/.env.local @@ -4,7 +4,7 @@ DATABASE_URL=postgresql://localhost:5432/mydb # API keys API_KEY=existing-key # inline comment here -SPECIAL_FLAG=local-value-different-from-remote +SPECIAL_FLAG="local-value-different-from-remote" # quotes will be preserved EXISTING_LOCAL_ONLY=this-should-stay # End of file comment \ No newline at end of file diff --git a/packages/cli/test/unit/commands/env/pull.test.ts b/packages/cli/test/unit/commands/env/pull.test.ts index bca480b8f632..85dec6097a57 100644 --- a/packages/cli/test/unit/commands/env/pull.test.ts +++ b/packages/cli/test/unit/commands/env/pull.test.ts @@ -338,7 +338,7 @@ describe('env pull', () => { 'Downloading `development` Environment Variables for' ); await expect(client.stderr).toOutput( - '+ SPECIAL_FLAG (Updated)\n+ NEW_VAR\n- TEST\n' + '+ SPECIAL_FLAG (Updated)\n+ NEW_VAR\n' ); await expect(client.stderr).toOutput( 'Updated .env.local file and added it to .gitignore' @@ -577,7 +577,7 @@ describe('env pull', () => { expect(originalContent).toContain('# inline comment here'); expect(originalContent).toContain('# End of file comment'); expect(originalContent).toContain( - 'SPECIAL_FLAG=local-value-different-from-remote' + 'SPECIAL_FLAG="local-value-different-from-remote"' ); expect(originalContent).toContain('EXISTING_LOCAL_ONLY=this-should-stay'); @@ -606,7 +606,7 @@ describe('env pull', () => { expect(updatedContent).toContain('# End of file comment'); // The remote SPECIAL_FLAG=1 should override the local value - expect(updatedContent).toContain('SPECIAL_FLAG=1'); + expect(updatedContent).toContain('SPECIAL_FLAG="1"'); expect(updatedContent).not.toContain( 'SPECIAL_FLAG=local-value-different-from-remote' ); From 6acf64db785af1f550b68cc4f2f5b4eea2c3c759 Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 01:32:19 +0200 Subject: [PATCH 11/12] Review comments Signed-off-by: Alexis Rico --- packages/cli/src/util/env/diff-env-files.ts | 29 +++++++++++++++------ 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/util/env/diff-env-files.ts b/packages/cli/src/util/env/diff-env-files.ts index 89c8e5292eb4..5a332789c01e 100644 --- a/packages/cli/src/util/env/diff-env-files.ts +++ b/packages/cli/src/util/env/diff-env-files.ts @@ -3,14 +3,22 @@ import type { Dictionary } from '@vercel/client'; import chalk from 'chalk'; import { existsSync, outputFile, readFile } from 'fs-extra'; import { dirname, join } from 'path'; +import output from '../../output-manager'; export async function createEnvObject( envPath: string ): Promise | undefined> { - const content = await readFile(envPath, 'utf-8'); - const privateKey = await getEncryptionKey(envPath); + try { + const content = await readFile(envPath, 'utf-8'); + const privateKey = await getEncryptionKey(envPath); - return dotenvx.parse(content, { processEnv: {}, privateKey }); + return dotenvx.parse(content, { processEnv: {}, privateKey }); + } catch (error) { + output.debug( + `Failed to parse env file: ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + } } export async function updateEnvFile( @@ -58,11 +66,16 @@ async function getEncryptionKey(envPath: string): Promise { return undefined; } - const keys = dotenvx.parse(await readFile(keysPath, 'utf8'), { - processEnv: {}, - }); - - return keys.DOTENV_PRIVATE_KEY; + try { + const content = await readFile(keysPath, 'utf8'); + const keys = dotenvx.parse(content, { processEnv: {} }); + return keys.DOTENV_PRIVATE_KEY; + } catch (error) { + output.debug( + `Failed to read encryption key from ${keysPath}: ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + } } function findChanges( From 032110b8f0e45fd4a8bebcc5e7d9f14e624cbed0 Mon Sep 17 00:00:00 2001 From: Alexis Rico Date: Mon, 25 Aug 2025 10:28:50 +0200 Subject: [PATCH 12/12] Fix mutation warning Signed-off-by: Alexis Rico --- packages/cli/src/util/env/diff-env-files.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/util/env/diff-env-files.ts b/packages/cli/src/util/env/diff-env-files.ts index 5a332789c01e..62dd8646df8c 100644 --- a/packages/cli/src/util/env/diff-env-files.ts +++ b/packages/cli/src/util/env/diff-env-files.ts @@ -95,9 +95,8 @@ function findChanges( } else if (oldEnv[key] !== newEnv[key]) { changed.push(key); } - delete oldEnv[key]; } - const removed = Object.keys(oldEnv); + const removed = Object.keys(oldEnv).filter(key => !(key in newEnv)); return { added,