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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.task
.vscode
.env
.ds_store
node_modules

dotenv-*.tgz
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,13 @@ The default behavior is to output a single line value. If you want to output a m
you can use the `--multiline` flag:

```shell
$ dotenv RSA_KEY
-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf...

$ dotenv RSA_KEY --multiline
-----BEGIN RSA PRIVATE KEY-----
MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Qu
KUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm


$ dotenv RSA_KEY
-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFE...
```

### Setting a Value
Expand All @@ -58,8 +57,25 @@ Set a value in a .env file:
dotenv <key> --set <value>
```

Quotes will be added if needed, but you can also force them:
Or pipe a value in:

```shell
echo <value> | dotenv <key>
```

This example will
- Generate a new RSA key pair and store it in the .env file
- Utilizing the stored private key it will generate a public key and store it in the .env file

```shell
openssl genpkey -algorithm RSA -outform PEM -pkeyopt rsa_keygen_bits:2048 2>/dev/null | dotenv RSA_KEY
dotenv RSA_KEY --multiline | openssl rsa -pubout 2>/dev/null | dotenv RSA_PUB
```

### Deleting a Value

Delete a value from a .env file:

```shell
dotenv <key> --set <value> --quote
dotenv <key> --delete
```
56 changes: 53 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as url from 'node:url';

import log, {setLogDebug} from './log.js';
import escapeAndQuote from "./escapeAndQuote.js";
import readPipe from "./readPipe.js";

async function app() {
const installDir: string = path.dirname(url.fileURLToPath(import.meta.url));
Expand All @@ -27,6 +28,7 @@ async function app() {
.option('-m, --multiline', 'Allow multiline values')
.option('-s, --set <value>', 'Update the environment variable in the .env file')
.option('-q, --quote', 'Quote the value when --set regardless of need')
.option('-D, --delete', 'Delete the environment variable from the .env file')
.option('-d, --debug', 'Output extra debugging')
.showSuggestionAfterError(true)
.parse(process.argv);
Expand All @@ -35,6 +37,10 @@ async function app() {

setLogDebug(options.debug);

const stdin: string | void = await readPipe().catch((err) => {
throw new RuleViolation(`Error reading from stdin: ${err}`);
});

const envFilePath: string = options.file || '.env';
const fullEnvPath: string = path.resolve(envFilePath);
const keys: string[] = program.args;
Expand All @@ -45,38 +51,82 @@ async function app() {
log.debug('Key count (0 or >1) defaulting to JSON');
options.json = true;
}

// Determine if we are setting a value, and if so, what's the value
let setValue: string = '';
if (stdin && set) {
// - cannot have both --set [value] and stdin
throw new RuleViolation('Cannot use --set and stdin together');
} else if (stdin) {
setValue = stdin;
} else if (set) {
setValue = set;
}

log.debug('Keys:', keys);
log.debug('Options:', options);
log.debug('File:', fullEnvPath);

const json: boolean = (options.json !== undefined);
const multiline: boolean = (options.multiline !== undefined);
const quoteSet: boolean = (options.quote !== undefined);
const deleteKey: boolean = (options.delete !== undefined);
const singleKey: boolean = (keys.length === 1);

// Qualifying Rules
// - must have a .env file
if (!fs.existsSync(envFilePath)) {
throw new RuleViolation(`.env file not found: ${fullEnvPath}`);
}
// - cannot have both --json and --set
if (json && set) {
if (json && setValue) {
throw new RuleViolation('Cannot use --json and --set together');
}
// - must have a key if using --set
if (set && (!keys.length || keys.length > 1)) {
if (setValue && !singleKey) {
throw new RuleViolation('Must specify a single key when using --set');
}
// - cannot have both --json and --multiline
if (json && multiline) {
throw new RuleViolation('Cannot use --json and --multiline together');
}
// - cannot use --delete with any other options
if (deleteKey && (setValue || json || multiline)) {
throw new RuleViolation('Cannot use --delete with any other options');
}
// - must have a key if using --delete
if (deleteKey && !singleKey) {
throw new RuleViolation('Must specify a single key when using --delete');
}

let envObject = parseEnvFile(envFilePath);

if (json && !keys.length) {
log.debug('Outputting entire .env file as JSON');
log.info(envObject.toJsonString());
} else if (set) {
} else if (deleteKey) {
const key: string = keys[0];

log.debug(`Deleting "${key}"`);

if (envObject[key]) {
const lineStart = envObject[key].lineStart;
const lineEnd = envObject[key].lineEnd;
log.debug(`Deleting lines ${lineStart}-${lineEnd}`);

// Read the file and split it into an array of lines
let lines: string[] = fs.readFileSync(envFilePath, 'utf8').split('\n');

// Remove the lines between lineStart and lineEnd
lines.splice(lineStart, lineEnd - lineStart + 1);

// Join the lines back together and write the result back to the file
fs.writeFileSync(envFilePath, lines.join('\n'));
} else {
log.debug(`Environment variable "${key}" not found`);
process.exitCode = 1;
}
} else if (setValue) {
const key: string = keys[0];
const newValue: string = escapeAndQuote(setValue, quoteSet);
const newLines: string = `${key}=${newValue}`;
Expand Down
34 changes: 34 additions & 0 deletions src/readPipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {Interface, createInterface} from 'node:readline';
import {stdin as input, stdout as output} from 'node:process';

/**
* Read from the pipe and return the data as a string
*/
const readPipe = (): Promise<string> => {
return new Promise((resolve, reject) => {
// If the stdin is a TTY device aka no pipe, resolve the promise with an empty string
if (input.isTTY) {
resolve('');
return;
}

const rl: Interface = createInterface({input, output});

let inputData: string = '';

rl.on('line', (input) => {
inputData += input + '\n';
});

rl.on('close', () => {
inputData = inputData.trim();
resolve(inputData);
});

rl.on('error', (err) => {
reject(err);
});
});
}

export default readPipe;
85 changes: 85 additions & 0 deletions tests/setAndDelete.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {execSync} from 'child_process';
import path from 'path';

describe('app.ts', () => {
const appPath: string = path.resolve(__dirname, '../build/app.js');
const envPath: string = path.resolve(__dirname, '.env.test');
const shaHash: string = execSync(`shasum -a 256 ${envPath}`).toString().split(' ')[0];

it('should update an existing key with a new value', () => {
const setCommand: Buffer = execSync(`node ${appPath} NEW_KEY --set VERY_NEW --file ${envPath}`);
const getCommand: Buffer = execSync(`node ${appPath} NEW_KEY --file ${envPath}`);

expect(setCommand.toString().trim()).toBe('');
expect(getCommand.toString().trim()).toBe('VERY_NEW');
});

it('should delete an existing key', () => {
const delCommand: Buffer = execSync(`node ${appPath} NEW_KEY --delete --file ${envPath}`);
const getCommand: Buffer = execSync(`node ${appPath} --file ${envPath}`);
const allJson: any = JSON.parse(getCommand.toString().trim());
const keys: string[] = Object.keys(allJson);

expect(delCommand.toString().trim()).toBe('');
expect(keys).not.toContain('NEW_KEY');
});

it('should add a new key with a multiline value as a single line', () => {
const setCommand: Buffer = execSync(`node ${appPath} NEW_ONE --set "This is a\nmultiline value" --file ${envPath}`);
const getCommand: Buffer = execSync(`node ${appPath} NEW_ONE --file ${envPath}`);

expect(setCommand.toString().trim()).toBe('');
// Note: We're escaping the newline character in the string
expect(getCommand.toString().trim()).toBe('This is a\\nmultiline value');
});

it('should add a new key with a multiline value', () => {
const setCommand: Buffer = execSync(`node ${appPath} NEW_TWO --set "This is a\nmultiline value" --file ${envPath}`);
const getCommand: Buffer = execSync(`node ${appPath} NEW_TWO --file ${envPath}`);

expect(setCommand.toString().trim()).toBe('');
expect(getCommand.toString().trim()).toBe('This is a\\nmultiline value');
});

it('should update an existing key without disturbing key/values below it', () => {
// This also tests that new keys are added to the end of the .env file
const setCommand: Buffer = execSync(`node ${appPath} NEW_ONE --set "Single line value" --file ${envPath}`);
const getCommand: Buffer = execSync(`node ${appPath} --file ${envPath}`);
const allJson: any = JSON.parse(getCommand.toString().trim());
const keys: string[] = Object.keys(allJson);
const lastKey: string = keys[keys.length - 1];
const lastValue: string = allJson[lastKey];

expect(setCommand.toString().trim()).toBe('');
expect(allJson['NEW_ONE']).toBe('Single line value');
expect(lastKey).toBe('NEW_TWO');
expect(lastValue).toBe('This is a\nmultiline value');
});

it('should update an existing key with a stdin value', () => {
const setCommand: Buffer = execSync(`echo "New stdin value" | node ${appPath} NEW_TWO --file ${envPath}`);
const getCommand: Buffer = execSync(`node ${appPath} NEW_TWO --file ${envPath}`);

expect(setCommand.toString().trim()).toBe('');
expect(getCommand.toString().trim()).toBe('New stdin value');
});

it('should remove all new test keys', () => {
const delOne: Buffer = execSync(`node ${appPath} NEW_ONE --delete --file ${envPath}`);
const delTwo: Buffer = execSync(`node ${appPath} NEW_TWO --delete --file ${envPath}`);
const getCommand: Buffer = execSync(`node ${appPath} --file ${envPath}`);
const allJson: any = JSON.parse(getCommand.toString().trim());
const keys: string[] = Object.keys(allJson);

expect(delOne.toString().trim()).toBe('');
expect(delTwo.toString().trim()).toBe('');
expect(keys).not.toContain('NEW_ONE');
expect(keys).not.toContain('NEW_TWO');
});

it('should not change the .env file hash', () => {
const hash: string = execSync(`shasum -a 256 ${envPath}`).toString().split(' ')[0];
expect(hash).toBe(shaHash);
});

});