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/README.md b/README.md index d21aff0..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,8 +57,25 @@ Set a value in a .env file: dotenv --set ``` -Quotes will be added if needed, but you can also force them: +Or pipe a value in: + +```shell +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 +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 --set --quote +dotenv --delete ``` diff --git a/src/app.ts b/src/app.ts index 42be796..fe0cd1b 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)); @@ -27,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); @@ -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; @@ -45,6 +51,18 @@ 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); @@ -52,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 @@ -59,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/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; diff --git a/tests/setAndDelete.tests.ts b/tests/setAndDelete.tests.ts new file mode 100644 index 0000000..5833677 --- /dev/null +++ b/tests/setAndDelete.tests.ts @@ -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); + }); + +});