From 5ccb0f2053441665f9e7cdb3e70f18520b170957 Mon Sep 17 00:00:00 2001 From: Patrick Heneise Date: Fri, 21 Nov 2025 10:57:04 -0700 Subject: [PATCH] refactor: improve code quality, performance, and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix whitespace handling: trim instead of stripping all spaces to preserve values like "Hello World" and database URLs with spaces - Add better error handling: distinguish between expected (ENOENT) and unexpected errors (permissions, corruption) - Optimize performance: use Set-based lookup (O(n)) instead of .some() loop (O(n²)) - Update README.md with merge behavior documentation - Update CLAUDE.md with implementation details and development notes - Add comprehensive tests for space preservation and edge cases All 12 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 8 +++++++- README.md | 8 +++++++- index.js | 11 ++++++++--- lib/readEnv.js | 4 ++-- lib/readExample.js | 4 ++-- test/merge.test.js | 41 +++++++++++++++++++++++++++++++++++++++++ test/readEnv.test.js | 13 +++++++++++++ 7 files changed, 80 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 389d5fb..cb8e748 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,10 +56,16 @@ Tests use `esmock` for mocking ES modules. The test suite includes: - Keys in existing `.env` that aren't in `.env.example` are preserved - Order: keys from `.env.example` appear first, followed by preserved keys - If no `.env` exists, creates a new file (original behavior) + - Uses Set-based lookup for O(n) performance when checking for duplicate keys - The GCP project ID is parsed from the first `GCP_PROJECT` or `GCLOUD_PROJECT_ID` variable in the file - Empty lines in `.env.example` are preserved in the output - The tool uses Node's native fs/promises API for file operations - Splitting on `=` uses a regex `/=(.*)/s` to only split on the first `=`, preserving `=` in values -- Whitespace is stripped from both keys and values during parsing +- Whitespace is only trimmed from the beginning/end of keys and values, preserving internal spaces - The `GCP_PROJECT` variable must appear **before** any `envsync//` variables, otherwise an error is thrown - All secrets are fetched concurrently using `Promise.all()` for performance +- Error handling distinguishes between expected errors (file not found) and unexpected errors (permissions, corruption) + +### Development Notes +- This project uses ESLint 8.x with the legacy `.eslintrc` config format +- Dependency updates (especially ESLint 9.x upgrade) are kept in separate PRs to maintain focus and avoid breaking changes requiring config migration diff --git a/README.md b/README.md index 3522208..41bd9da 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ environment or update environment variables with on a single source of truth. `EnvSync` currently works with Google Cloud Platform (Secrets Manager). It reads the environment configuration from an `.env.example` file that is commonly used to help developers get started with a new project, fetches the values from the -Google Cloud Platform (Secrets Manager) and writes them to a `.env` file. +Google Cloud Platform (Secrets Manager) and intelligently merges them with your +existing `.env` file. + +**Merge Behavior**: When you run `envsync`, it will: +- Update keys from `.env.example` in your existing `.env` file +- Preserve any custom keys in `.env` that aren't in `.env.example` +- Create a new `.env` file if one doesn't exist For example, if you have the following `.env.example` file: diff --git a/index.js b/index.js index 6eb518c..9cf6876 100755 --- a/index.js +++ b/index.js @@ -20,7 +20,11 @@ try { const existingFile = await fs.readFile(output, 'utf8') existingEnv = readEnv(existingFile) } catch (err) { - // File doesn't exist, that's okay + // File doesn't exist, that's okay - we'll create a new one + if (err.code !== 'ENOENT') { + // Unexpected error (permissions, corrupted file, etc.) + console.error(`Warning: Could not read existing ${output}: ${err.message}`) + } } // Merge: update existing keys with new values from .env.example @@ -37,9 +41,10 @@ const mergedEnv = env.map(([key]) => { }) // Add any remaining keys from existing .env that weren't in .env.example +// Use Set for O(n) performance instead of O(n²) +const exampleKeys = new Set(env.map(([key]) => key).filter((key) => key !== '')) for (const [key, value] of existingEnv.entries()) { - const existsInExample = env.some(([k]) => k === key) - if (!existsInExample) { + if (!exampleKeys.has(key)) { mergedEnv.push([key, value]) } } diff --git a/lib/readEnv.js b/lib/readEnv.js index 3159759..71de6a2 100644 --- a/lib/readEnv.js +++ b/lib/readEnv.js @@ -12,8 +12,8 @@ export function readEnv(envFile) { .split('\n') .map((line) => line.split(/=(.*)/s)) .forEach(([key, value]) => { - key = key?.replace(/\s/g, '') || '' - value = value?.replace(/\s/g, '') || '' + key = key?.trim() || '' + value = value?.trim() || '' if (key !== '') { envMap.set(key, value) diff --git a/lib/readExample.js b/lib/readExample.js index c43049b..33d0384 100644 --- a/lib/readExample.js +++ b/lib/readExample.js @@ -14,8 +14,8 @@ export async function readExample(exampleFile) { .split('\n') .map((line) => line.split(/=(.*)/s)) .map(([key, value]) => { - key = key.replace(/\s/g, '') - value = value?.replace(/\s/g, '') + key = key.trim() + value = value?.trim() // set gcp project id for secret manager if (key === 'GCP_PROJECT') { diff --git a/test/merge.test.js b/test/merge.test.js index 58d70ba..9b9613d 100644 --- a/test/merge.test.js +++ b/test/merge.test.js @@ -128,3 +128,44 @@ NEW_KEY=new-value await fs.rm(tmpDir, { recursive: true, force: true }) } }) + +test('merge behavior: preserves values with spaces', async () => { + const tmpDir = await fs.mkdtemp(path.join(__dirname, 'tmp-')) + const examplePath = path.join(tmpDir, '.env.example') + const envPath = path.join(tmpDir, '.env') + + try { + // Create .env.example + await fs.writeFile( + examplePath, + `GCP_PROJECT=test-project +MESSAGE=Hello World +` + ) + + // Create existing .env with value containing spaces + await fs.writeFile( + envPath, + `GCP_PROJECT=test-project +DATABASE_URL=postgresql://user:my pass@localhost:5432/db +` + ) + + // Run envsync + await execFileAsync('node', [path.join(rootDir, 'index.js'), examplePath], { + cwd: tmpDir + }) + + // Read result + const result = await fs.readFile(envPath, 'utf8') + const lines = result.split('\n') + + // Should preserve spaces in both new and existing values + assert.ok(lines.some((line) => line === 'MESSAGE=Hello World')) + assert.ok( + lines.some((line) => line === 'DATABASE_URL=postgresql://user:my pass@localhost:5432/db') + ) + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) + } +}) diff --git a/test/readEnv.test.js b/test/readEnv.test.js index 4d7e12e..410389c 100644 --- a/test/readEnv.test.js +++ b/test/readEnv.test.js @@ -45,3 +45,16 @@ JWT_SECRET=abc123== assert.equal(result.get('DATABASE_URL'), 'postgresql://user:pass=123@localhost:5432/db') assert.equal(result.get('JWT_SECRET'), 'abc123==') }) + +test('readEnv() preserves spaces in values', () => { + const envFile = `MESSAGE=Hello World +DATABASE_URL=postgresql://user:my pass@localhost:5432/db +QUOTED="value with spaces" +` + + const result = readEnv(envFile) + + assert.equal(result.get('MESSAGE'), 'Hello World') + assert.equal(result.get('DATABASE_URL'), 'postgresql://user:my pass@localhost:5432/db') + assert.equal(result.get('QUOTED'), '"value with spaces"') +})