Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
11 changes: 8 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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])
}
}
Expand Down
4 changes: 2 additions & 2 deletions lib/readEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions lib/readExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
41 changes: 41 additions & 0 deletions test/merge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
})
13 changes: 13 additions & 0 deletions test/readEnv.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"')
})