Skip to content

Commit

Permalink
✨ Feature: Preserve Existing .env File and Update Values if Present (
Browse files Browse the repository at this point in the history
  • Loading branch information
macalbert authored Oct 16, 2024
1 parent 40d519a commit 65fbc72
Show file tree
Hide file tree
Showing 6 changed files with 510 additions and 405 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "envilder",
"version": "0.1.5",
"version": "0.2.1",
"description": "A CLI tool to generate .env files from AWS SSM parameters",
"exports": {
".": {
Expand Down Expand Up @@ -42,6 +42,7 @@
"@secretlint/secretlint-rule-preset-recommend": "^8.2.4",
"@types/node": "^22.5.5",
"commander": "^12.1.0",
"dotenv": "^16.4.5",
"picocolors": "^1.1.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/cliRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@ export async function cliRunner() {
}

cliRunner().catch((error) => {
console.error('Error in CLI Runner:', error);
console.error('🚨 Uh-oh! Looks like Mario fell into the wrong pipe! 🍄💥');
});
86 changes: 69 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,88 @@
import * as fs from 'node:fs';
import { GetParameterCommand, SSM } from '@aws-sdk/client-ssm';
import * as dotenv from 'dotenv';

const ssm = new SSM({});

export async function run(mapPath: string, envFilePath: string) {
const paramMap = loadParamMap(mapPath);
const existingEnvVariables = loadExistingEnvVariables(envFilePath);

const updatedEnvVariables = await fetchAndUpdateEnvVariables(paramMap, existingEnvVariables);

writeEnvFile(envFilePath, updatedEnvVariables);
console.log(`Environment File generated at '${envFilePath}'`);
}

function loadParamMap(mapPath: string): Record<string, string> {
const content = fs.readFileSync(mapPath, 'utf-8');
const paramMap: Record<string, string> = JSON.parse(content);
try {
return JSON.parse(content);
} catch (error) {
console.error(`Error parsing JSON from ${mapPath}`);
throw new Error(`Invalid JSON in parameter map file: ${mapPath}`);
}
}

function loadExistingEnvVariables(envFilePath: string): Record<string, string> {
const envVariables: Record<string, string> = {};

if (!fs.existsSync(envFilePath)) return envVariables;

const existingEnvContent = fs.readFileSync(envFilePath, 'utf-8');
const parsedEnv = dotenv.parse(existingEnvContent);
Object.assign(envVariables, parsedEnv);

return envVariables;
}

const envContent: string[] = [];
async function fetchAndUpdateEnvVariables(
paramMap: Record<string, string>,
existingEnvVariables: Record<string, string>,
): Promise<Record<string, string>> {
const errors: string[] = [];

console.log('Fetching parameters...');
for (const [envVar, ssmName] of Object.entries(paramMap)) {
try {
const command = new GetParameterCommand({
Name: ssmName,
WithDecryption: true,
});

const { Parameter } = await ssm.send(command);
const value = Parameter?.Value;
const value = await fetchSSMParameter(ssmName);
if (value) {
envContent.push(`${envVar}=${value}`);
console.log(`${envVar}=${value}`);
existingEnvVariables[envVar] = value;
console.log(
`${envVar}=${value.length > 3 ? '*'.repeat(value.length - 3) + value.slice(-3) : '*'.repeat(value.length)}`,
);
} else {
console.error(`Warning: No value found for ${ssmName}`);
console.error(`Warning: No value found for: '${ssmName}'`);
}
} catch (error) {
console.error(`Error fetching parameter ${ssmName}: ${error}`);
throw new Error(`ParameterNotFound: ${ssmName}`);
console.error(`Error fetching parameter: '${ssmName}'`);
errors.push(`ParameterNotFound: ${ssmName}`);
}
}

fs.writeFileSync(envFilePath, envContent.join('\n'));
console.log(`.env file generated at ${envFilePath}`);
if (errors.length > 0) {
throw new Error(`Some parameters could not be fetched:\n${errors.join('\n')}`);
}

return existingEnvVariables;
}

async function fetchSSMParameter(ssmName: string): Promise<string | undefined> {
const command = new GetParameterCommand({
Name: ssmName,
WithDecryption: true,
});

const { Parameter } = await ssm.send(command);
return Parameter?.Value;
}

function writeEnvFile(envFilePath: string, envVariables: Record<string, string>): void {
const envContent = Object.entries(envVariables)
.map(([key, value]) => {
const escapedValue = value.replace(/(\n|\r|\n\r)/g, '\\n').replace(/"/g, '\\"');
return `${key}=${escapedValue}`;
})
.join('\n');

fs.writeFileSync(envFilePath, envContent);
}
76 changes: 74 additions & 2 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ vi.mock('@aws-sdk/client-ssm', () => {
Parameter: { Value: '[email protected]' },
});
}

if (command.input.Name === '/path/to/ssm/password') {
return Promise.resolve({
Parameter: { Value: 'mockedPassword' },
});
}

if (command.input.Name === '/path/to/ssm/password_no_value') {
return Promise.resolve({ Parameter: { Value: '' } });
}

return Promise.reject(new Error(`ParameterNotFound: ${command.input.Name}`));
}),
})),
Expand Down Expand Up @@ -57,15 +62,82 @@ describe('Envilder CLI', () => {
const mockMapPath = './tests/param_map.json';
const mockEnvFilePath = './tests/.env.test';
const paramMapContent = {
NEXT_PUBLIC_CREDENTIAL_EMAIL: '/path/to/ssm/unknown-email', // Non-existent parameter
NEXT_PUBLIC_CREDENTIAL_EMAIL: 'non-existent parameter',
};
fs.writeFileSync(mockMapPath, JSON.stringify(paramMapContent));

// Act
const action = run(mockMapPath, mockEnvFilePath);

// Assert
await expect(action).rejects.toThrow('ParameterNotFound: /path/to/ssm/unknown-email');
await expect(action).rejects.toThrow('ParameterNotFound: non-existent parameter');
fs.unlinkSync(mockMapPath);
});

it('Should_AppendNewSSMParameters_When_EnvFileContainsExistingVariables', async () => {
// Arrange
const mockMapPath = './tests/param_map.json';
const mockEnvFilePath = './tests/.env.test';

const existingEnvContent = 'EXISTING_VAR=existingValue';
fs.writeFileSync(mockEnvFilePath, existingEnvContent);
const paramMapContent = {
NEXT_PUBLIC_CREDENTIAL_EMAIL: '/path/to/ssm/email',
NEXT_PUBLIC_CREDENTIAL_PASSWORD: '/path/to/ssm/password',
};
fs.writeFileSync(mockMapPath, JSON.stringify(paramMapContent));

// Act
await run(mockMapPath, mockEnvFilePath);

// Assert
const updatedEnvFileContent = fs.readFileSync(mockEnvFilePath, 'utf-8');
expect(updatedEnvFileContent).toContain('EXISTING_VAR=existingValue');
expect(updatedEnvFileContent).toContain('[email protected]');
expect(updatedEnvFileContent).toContain('NEXT_PUBLIC_CREDENTIAL_PASSWORD=mockedPassword');
fs.unlinkSync(mockEnvFilePath);
fs.unlinkSync(mockMapPath);
});

it('Should_OverwriteSSMParameters_When_EnvFileContainsSameVariables', async () => {
// Arrange
const mockMapPath = './tests/param_map.json';
const mockEnvFilePath = './tests/.env.test';
const existingEnvContent = '[email protected]';
fs.writeFileSync(mockEnvFilePath, existingEnvContent);
const paramMapContent = {
NEXT_PUBLIC_CREDENTIAL_EMAIL: '/path/to/ssm/email',
NEXT_PUBLIC_CREDENTIAL_PASSWORD: '/path/to/ssm/password',
};
fs.writeFileSync(mockMapPath, JSON.stringify(paramMapContent));

// Act
await run(mockMapPath, mockEnvFilePath);

// Assert
const updatedEnvFileContent = fs.readFileSync(mockEnvFilePath, 'utf-8');
expect(updatedEnvFileContent).toContain('[email protected]');
expect(updatedEnvFileContent).toContain('NEXT_PUBLIC_CREDENTIAL_PASSWORD=mockedPassword');
fs.unlinkSync(mockEnvFilePath);
fs.unlinkSync(mockMapPath);
});

it('Should_LogWarning_When_SSMParameterHasNoValue', async () => {
// Arrange
const mockMapPath = './tests/param_map.json';
const mockEnvFilePath = './tests/.env.test';
const paramMapContent = {
NEXT_PUBLIC_CREDENTIAL_PASSWORD: '/path/to/ssm/password_no_value',
};
fs.writeFileSync(mockMapPath, JSON.stringify(paramMapContent));
const consoleSpy = vi.spyOn(console, 'error');

// Act
await run(mockMapPath, mockEnvFilePath);

// Assert
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: No value found for'));
fs.unlinkSync(mockEnvFilePath);
fs.unlinkSync(mockMapPath);
});
});
4 changes: 1 addition & 3 deletions tests/sample/param_map.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
{
"NEXT_PUBLIC_CREDENTIAL_EMAIL": "/M47.Claims.Apps.Minimal.Api/Production/Auth/CredentialEmail",
"NEXT_PUBLIC_CREDENTIAL_PASSWORD": "/M47.Claims.Apps.Minimal.Api/Production/Auth/CredentialPassword",
"NEXT_PUBLIC_JWT_SECRET": "/M47.Claims.Apps.Minimal.Api/Production/Auth/JwtSecret"
"TOKEN_SECRET": "/Test/Token"
}
Loading

0 comments on commit 65fbc72

Please sign in to comment.