diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..389d5fb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +EnvSync is a CLI tool that synchronizes environment variables from Google Cloud Platform (GCP) Secrets Manager to local `.env` files. It parses `.env.example` files, identifies variables marked with the `envsync//` prefix, fetches their values from GCP Secrets Manager, and writes them to a `.env` file. + +## Commands + +### Development +- `npm run dev` - Run in development mode with file watching +- `npm start` - Run the CLI normally +- `npx envsync [file]` - Execute the tool directly (default: `.env.example`) + +### Testing +- `npm test` - Run all tests using Node's native test runner with esmock +- `node --loader=esmock --test` - Direct test command + +### Code Quality +- `npm run lint` - Run ESLint on JavaScript and Markdown files +- `npm run codestyle` - Format code with Prettier + +### Requirements +- Node.js >= 20 +- A `keyfile.json` for GCP service account authentication must be present in the working directory +- The first variable in `.env.example` must be `GCP_PROJECT` or `GCLOUD_PROJECT_ID` + +## Architecture + +### Core Flow +1. **Entry Point** (`index.js`): CLI entry that reads the `.env.example` file (or custom file via argument), checks for existing `.env`, and orchestrates the synchronization with merge behavior +2. **Parser** (`lib/readExample.js`): Splits `.env.example` into lines, identifies which variables need syncing (contain `envsync//`), and extracts the GCP project ID +3. **Env Reader** (`lib/readEnv.js`): Parses existing `.env` files into a Map for merging +4. **Fetcher** (`lib/fetchVariable.js`): Connects to GCP Secrets Manager and retrieves secret values using the format `envsync//[secret-name]/[version]` (version defaults to 'latest') +5. **Merger** (in `index.js`): Merges values from `.env.example` with existing `.env`, preserving keys not in the example +6. **Writer** (`lib/joinEnv.js`): Reconstructs the environment file format from key-value pairs + +### Variable Syntax +Variables that should be fetched from GCP Secrets Manager use this format: +``` +VARIABLE_NAME=envsync//secret-name/version +``` + +The `secret-name` maps to a secret in GCP Secrets Manager, and `version` is optional (defaults to 'latest'). + +### Testing Strategy +Tests use `esmock` for mocking ES modules. The test suite includes: +- Unit tests for each core function +- Integration-style test for the full read-parse-fetch-join flow +- Mock implementations of the GCP Secrets Manager client + +### Key Design Decisions +- **Merge Behavior**: The tool performs intelligent merging instead of overwriting: + - Keys from `.env.example` override/update existing keys in `.env` + - 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) +- 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 +- 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 diff --git a/index.js b/index.js index 6f0e2e4..6eb518c 100755 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ import fs from 'fs/promises' import { readExample } from './lib/readExample.js' +import { readEnv } from './lib/readEnv.js' import { joinEnv } from './lib/joinEnv.js' const args = process.argv.slice(2); @@ -12,4 +13,35 @@ const filePath = args[0] || '.env.example' const exampleFile = await fs.readFile(filePath, 'utf8') const env = await readExample(exampleFile) const output = filePath.replace(".example", "") -await fs.writeFile(output, joinEnv(env)) + +// Check if .env file already exists +let existingEnv = new Map() +try { + const existingFile = await fs.readFile(output, 'utf8') + existingEnv = readEnv(existingFile) +} catch (err) { + // File doesn't exist, that's okay +} + +// Merge: update existing keys with new values from .env.example +env.forEach(([key, value]) => { + if (key !== '') { + existingEnv.set(key, value) + } +}) + +// Convert Map back to array format, maintaining order: example keys come first +const mergedEnv = env.map(([key]) => { + if (key === '') return [''] + return [key, existingEnv.get(key)] +}) + +// Add any remaining keys from existing .env that weren't in .env.example +for (const [key, value] of existingEnv.entries()) { + const existsInExample = env.some(([k]) => k === key) + if (!existsInExample) { + mergedEnv.push([key, value]) + } +} + +await fs.writeFile(output, joinEnv(mergedEnv)) diff --git a/lib/readEnv.js b/lib/readEnv.js new file mode 100644 index 0000000..3159759 --- /dev/null +++ b/lib/readEnv.js @@ -0,0 +1,24 @@ +'use strict' + +/** + * Parse an existing .env file into key-value pairs + * @param {string} envFile - Content of the .env file + * @returns {Map} Map of environment variables + */ +export function readEnv(envFile) { + const envMap = new Map() + + envFile + .split('\n') + .map((line) => line.split(/=(.*)/s)) + .forEach(([key, value]) => { + key = key?.replace(/\s/g, '') || '' + value = value?.replace(/\s/g, '') || '' + + if (key !== '') { + envMap.set(key, value) + } + }) + + return envMap +} diff --git a/test/merge.test.js b/test/merge.test.js new file mode 100644 index 0000000..58d70ba --- /dev/null +++ b/test/merge.test.js @@ -0,0 +1,130 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' +import { execFile } from 'child_process' +import { promisify } from 'util' + +const execFileAsync = promisify(execFile) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.join(__dirname, '..') + +test('merge behavior: preserves existing keys not in .env.example', 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 with GCP_PROJECT and one variable + await fs.writeFile( + examplePath, + `GCP_PROJECT=test-project +NEW_KEY=new-value +` + ) + + // Create existing .env with different keys + await fs.writeFile( + envPath, + `GCP_PROJECT=test-project +OLD_KEY=old-value +PRESERVED_KEY=should-remain +` + ) + + // 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 have NEW_KEY from example + assert.ok(lines.some((line) => line.includes('NEW_KEY=new-value'))) + + // Should preserve OLD_KEY and PRESERVED_KEY + assert.ok(lines.some((line) => line.includes('OLD_KEY=old-value'))) + assert.ok(lines.some((line) => line.includes('PRESERVED_KEY=should-remain'))) + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) + } +}) + +test('merge behavior: updates existing keys from .env.example', 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 +SHARED_KEY=updated-value +` + ) + + // Create existing .env with old value + await fs.writeFile( + envPath, + `GCP_PROJECT=test-project +SHARED_KEY=old-value +OTHER_KEY=preserved +` + ) + + // 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') + + // SHARED_KEY should be updated to new value + assert.ok(lines.some((line) => line === 'SHARED_KEY=updated-value')) + assert.ok(!lines.some((line) => line === 'SHARED_KEY=old-value')) + + // OTHER_KEY should be preserved + assert.ok(lines.some((line) => line.includes('OTHER_KEY=preserved'))) + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) + } +}) + +test('merge behavior: creates new .env if it does not exist', 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 +NEW_KEY=new-value +` + ) + + // Do not create .env - it should be created fresh + + // 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 have keys from example + assert.ok(lines.some((line) => line.includes('GCP_PROJECT=test-project'))) + assert.ok(lines.some((line) => line.includes('NEW_KEY=new-value'))) + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) + } +}) diff --git a/test/readEnv.test.js b/test/readEnv.test.js new file mode 100644 index 0000000..4d7e12e --- /dev/null +++ b/test/readEnv.test.js @@ -0,0 +1,47 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readEnv } from '../lib/readEnv.js' + +test('readEnv() parses .env file into Map', () => { + const envFile = `GCP_PROJECT=myproject-dev +NODE_ENV=development +API_URL=http://localhost:3000 + +AUTH0_CLIENT_SECRET=secret-value +DATABASE_URL=postgresql://user:pass@localhost:5432/db?schema=public +` + + const result = readEnv(envFile) + + assert.equal(result.get('GCP_PROJECT'), 'myproject-dev') + assert.equal(result.get('NODE_ENV'), 'development') + assert.equal(result.get('API_URL'), 'http://localhost:3000') + assert.equal(result.get('AUTH0_CLIENT_SECRET'), 'secret-value') + assert.equal(result.get('DATABASE_URL'), 'postgresql://user:pass@localhost:5432/db?schema=public') + assert.equal(result.size, 5) +}) + +test('readEnv() handles empty lines', () => { + const envFile = `KEY1=value1 + + +KEY2=value2 +` + + const result = readEnv(envFile) + + assert.equal(result.get('KEY1'), 'value1') + assert.equal(result.get('KEY2'), 'value2') + assert.equal(result.size, 2) +}) + +test('readEnv() handles values with equals signs', () => { + const envFile = `DATABASE_URL=postgresql://user:pass=123@localhost:5432/db +JWT_SECRET=abc123== +` + + const result = readEnv(envFile) + + assert.equal(result.get('DATABASE_URL'), 'postgresql://user:pass=123@localhost:5432/db') + assert.equal(result.get('JWT_SECRET'), 'abc123==') +})