From c26b3e623f376cf0fdb4e72b11ed5117b86c9299 Mon Sep 17 00:00:00 2001 From: Mike Garde Date: Mon, 1 Jul 2024 18:04:01 -0400 Subject: [PATCH 1/4] stdin utilized for set value --- README.md | 25 +++++++++++++++++++++++++ src/app.ts | 32 +++++++++++++++++++++++++++----- src/readPipe.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 src/readPipe.ts diff --git a/README.md b/README.md index d21aff0..402c537 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,28 @@ Quotes will be added if needed, but you can also force them: ```shell dotenv --set --quote ``` + +Or pipe a value in: + +```shell +echo | dotenv --set +``` + +```shell +openssl genpkey -algorithm RSA -outform PEM -pkeyopt rsa_keygen_bits:2048 2>/dev/null | dotenv RSA_KEY --set +dotenv RSA_KEY --multiline | openssl rsa -pubout 2>/dev/null | dotenv RSA_PUB --set +``` + +## Within Node + +You can also use dotenv-cli within a Node script to easily retrieve values from a .env file: + +```shell +npm i @mikegarde/dotenv-cli +``` + +```typescript +import { getEnv } from '@mikegarde/dotenv-cli'; + +const value = getEnv('KEY'); +``` diff --git a/src/app.ts b/src/app.ts index 42be796..5de4b9b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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)); @@ -25,7 +26,7 @@ async function app() { .option('-f, --file ', 'Specify the .env file (default: .env)') .option('-j, --json', 'Output as JSON') .option('-m, --multiline', 'Allow multiline values') - .option('-s, --set ', 'Update the environment variable in the .env file') + .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, --debug', 'Output extra debugging') .showSuggestionAfterError(true) @@ -35,16 +36,37 @@ async function app() { setLogDebug(options.debug); - const envFilePath: string = options.file || '.env'; - const fullEnvPath: string = path.resolve(envFilePath); - const keys: string[] = program.args; - const set: string = options.set; + 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; + const set: string | boolean = options.set; // Multiple keys or no keys assume --json if (keys.length > 1 || !keys.length) { 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 && typeof set === 'boolean') { + // --set && stdin without a value + throw new RuleViolation('Must specify a value when using --set'); + } else if (stdin && typeof set === 'string') { + // - cannot have both --set [value] and stdin + throw new RuleViolation('Cannot use --set and stdin together'); + } else if (stdin && set) { + // stdin will replace --set [value] but --set will trigger the action + setValue = stdin; + } else if (typeof set === 'string') { + // --set [value] will be used + setValue = set; + } + log.debug('Keys:', keys); log.debug('Options:', options); log.debug('File:', fullEnvPath); diff --git a/src/readPipe.ts b/src/readPipe.ts new file mode 100644 index 0000000..40cbbf7 --- /dev/null +++ b/src/readPipe.ts @@ -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 => { + 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; From e5c4348c5ef7fcc301e2d2780ba36cb6fbb3b522 Mon Sep 17 00:00:00 2001 From: Mike Garde Date: Mon, 1 Jul 2024 23:18:57 -0400 Subject: [PATCH 2/4] stdin will assume --set instead of parsing stdin and --set we're only going to allow one added unit testing need to add --delete option - will mirror unit testing --- .gitignore | 1 + src/app.ts | 21 ++++----- tests/setAndDelete.tests.ts | 90 +++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 tests/setAndDelete.tests.ts diff --git a/.gitignore b/.gitignore index a4ca876..b5c234d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .task .vscode .env +.ds_store node_modules dotenv-*.tgz diff --git a/src/app.ts b/src/app.ts index 5de4b9b..85bba6e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,7 +26,7 @@ async function app() { .option('-f, --file ', 'Specify the .env file (default: .env)') .option('-j, --json', 'Output as JSON') .option('-m, --multiline', 'Allow multiline values') - .option('-s, --set [value]', 'Update the environment variable in the .env file') + .option('-s, --set ', 'Update the environment variable in the .env file') .option('-q, --quote', 'Quote the value when --set regardless of need') .option('-d, --debug', 'Output extra debugging') .showSuggestionAfterError(true) @@ -40,10 +40,10 @@ async function app() { 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; - const set: string | boolean = options.set; + const envFilePath: string = options.file || '.env'; + const fullEnvPath: string = path.resolve(envFilePath); + const keys: string[] = program.args; + const set: string = options.set; // Multiple keys or no keys assume --json if (keys.length > 1 || !keys.length) { @@ -53,17 +53,12 @@ async function app() { // Determine if we are setting a value, and if so, what's the value let setValue: string = ''; - if (!stdin && typeof set === 'boolean') { - // --set && stdin without a value - throw new RuleViolation('Must specify a value when using --set'); - } else if (stdin && typeof set === 'string') { + if (stdin && set) { // - cannot have both --set [value] and stdin throw new RuleViolation('Cannot use --set and stdin together'); - } else if (stdin && set) { - // stdin will replace --set [value] but --set will trigger the action + } else if (stdin) { setValue = stdin; - } else if (typeof set === 'string') { - // --set [value] will be used + } else if (set) { setValue = set; } diff --git a/tests/setAndDelete.tests.ts b/tests/setAndDelete.tests.ts new file mode 100644 index 0000000..124c7b9 --- /dev/null +++ b/tests/setAndDelete.tests.ts @@ -0,0 +1,90 @@ +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 insert a new key and value to the end of the .env file', () => { + const setCommand: Buffer = execSync(`node ${appPath} NEW_KEY --set NEW_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_KEY']).toBe('NEW_VALUE'); + expect(lastKey).toBe('NEW_KEY'); + expect(lastValue).toBe('NEW_VALUE'); + }); + + 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} NAME --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} NAME --delete --file ${envPath}`); + const getCommand: Buffer = execSync(`node ${appPath} NAME --file ${envPath}`); + + expect(delCommand.toString().trim()).toBe(''); + expect(getCommand.toString().trim()).toBe(''); + }); + + 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(''); + expect(getCommand.toString().trim()).toBe('This is a multiline 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', () => { + const setCommand: Buffer = execSync(`node ${appPath} NEW_ONE --set "New double quotes" --file ${envPath}`); + const getCommand: Buffer = execSync(`node ${appPath} NEW_ONE --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('New double quotes'); + expect(lastKey).toBe('NEW_TWO'); + expect(lastValue).toBe('This is a\nmultiline value'); + }); + + it('should update an existing key with a stdin value', () => { + const result: Buffer = execSync(`echo "New stdin value" | node ${appPath} NEW_TWO --file ${envPath}`); + const getCommand: Buffer = execSync(`node ${appPath} NEW_TWO --file ${envPath}`); + + expect(result.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'); + }); + +}); From b149cde66c4e763ca4706ce71617dd2467eb6f98 Mon Sep 17 00:00:00 2001 From: Mike Garde Date: Tue, 2 Jul 2024 00:10:49 -0400 Subject: [PATCH 3/4] Adding --delete option and correcting unit tests --- src/app.ts | 39 ++++++++++++++++++++++++-- tests/setAndDelete.tests.ts | 55 +++++++++++++++++-------------------- 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/app.ts b/src/app.ts index 85bba6e..fe0cd1b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -28,6 +28,7 @@ async function app() { .option('-m, --multiline', 'Allow multiline values') .option('-s, --set ', '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); @@ -69,6 +70,8 @@ async function app() { 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 @@ -76,24 +79,54 @@ async function app() { 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}`; diff --git a/tests/setAndDelete.tests.ts b/tests/setAndDelete.tests.ts index 124c7b9..5833677 100644 --- a/tests/setAndDelete.tests.ts +++ b/tests/setAndDelete.tests.ts @@ -6,77 +6,67 @@ describe('app.ts', () => { const envPath: string = path.resolve(__dirname, '.env.test'); const shaHash: string = execSync(`shasum -a 256 ${envPath}`).toString().split(' ')[0]; - it('should insert a new key and value to the end of the .env file', () => { - const setCommand: Buffer = execSync(`node ${appPath} NEW_KEY --set NEW_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_KEY']).toBe('NEW_VALUE'); - expect(lastKey).toBe('NEW_KEY'); - expect(lastValue).toBe('NEW_VALUE'); - }); - 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} NAME --file ${envPath}`); + 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} NAME --delete --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} NAME --file ${envPath}`); + 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(getCommand.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 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(''); - expect(getCommand.toString().trim()).toBe('This is a multiline value'); + // 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 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'); + expect(getCommand.toString().trim()).toBe('This is a\\nmultiline value'); }); it('should update an existing key without disturbing key/values below it', () => { - const setCommand: Buffer = execSync(`node ${appPath} NEW_ONE --set "New double quotes" --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} NEW_ONE --file ${envPath}`); + // 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('New double quotes'); + 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 result: Buffer = execSync(`echo "New stdin value" | node ${appPath} NEW_TWO --file ${envPath}`); + 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(result.toString().trim()).toBe(''); + 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 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); @@ -87,4 +77,9 @@ describe('app.ts', () => { 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); + }); + }); From 71e4e129a2aaf8716d26df8e97341d91276123cd Mon Sep 17 00:00:00 2001 From: Mike Garde Date: Tue, 2 Jul 2024 00:18:28 -0400 Subject: [PATCH 4/4] Documentation corrections Also adding --delete command --- README.md | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 402c537..039773b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -58,33 +57,25 @@ Set a value in a .env file: dotenv --set ``` -Quotes will be added if needed, but you can also force them: - -```shell -dotenv --set --quote -``` - Or pipe a value in: ```shell -echo | dotenv --set +echo | dotenv ``` +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 --set -dotenv RSA_KEY --multiline | openssl rsa -pubout 2>/dev/null | dotenv RSA_PUB --set +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 ``` -## Within Node +### Deleting a Value -You can also use dotenv-cli within a Node script to easily retrieve values from a .env file: +Delete a value from a .env file: ```shell -npm i @mikegarde/dotenv-cli -``` - -```typescript -import { getEnv } from '@mikegarde/dotenv-cli'; - -const value = getEnv('KEY'); +dotenv --delete ```