@dotenvx/dotenvx
Advanced tools
Comparing version
{ | ||
"version": "0.7.4", | ||
"version": "0.8.0", | ||
"name": "@dotenvx/dotenvx", | ||
@@ -27,4 +27,13 @@ "description": "a better dotenv–from the creator of `dotenv`", | ||
"dependencies": { | ||
"@inquirer/prompts": "^3.3.0", | ||
"axios": "^1.6.2", | ||
"chalk": "^4.1.2", | ||
"clipboardy": "^2.3.0", | ||
"commander": "^11.1.0", | ||
"conf": "^10.2.0", | ||
"dotenv": "^16.3.1", | ||
"open": "^8.4.2", | ||
"ora": "^5.4.1", | ||
"qrcode-terminal": "^0.12.0", | ||
"update-notifier": "^5.1.0", | ||
"execa": "^5.1.1", | ||
@@ -31,0 +40,0 @@ "winston": "^3.11.0", |
@@ -116,2 +116,12 @@  | ||
</details> | ||
* <details><summary>Bash 🖥️</summary><br> | ||
```sh | ||
$ echo "HELLO=World" > .env | ||
$ dotenvx run --quiet -- sh -c 'echo $HELLO' | ||
World | ||
``` | ||
</details> | ||
* <details><summary>Frameworks ▲</summary><br> | ||
@@ -118,0 +128,0 @@ |
#!/usr/bin/env node | ||
const fs = require('fs') | ||
const updateNotifier = require('update-notifier') | ||
const { Command } = require('commander') | ||
const program = new Command() | ||
// constants | ||
const ENCODING = 'utf8' | ||
const logger = require('./../shared/logger') | ||
@@ -15,4 +12,9 @@ const helpers = require('./helpers') | ||
const packageJson = require('./../shared/packageJson') | ||
const main = require('./../lib/main') | ||
// once a day check for any updates | ||
const notifier = updateNotifier({ pkg: packageJson }) | ||
if (notifier.update) { | ||
logger.warn(`Update available ${notifier.update.current} → ${notifier.update.latest} [see changelog](dotenvx.com/changelog)`) | ||
} | ||
// global log levels | ||
@@ -62,115 +64,4 @@ program | ||
.option('-o, --overload', 'override existing env variables') | ||
.action(async function () { | ||
const options = this.opts() | ||
logger.debug('configuring options') | ||
logger.debug(options) | ||
.action(require('./actions/run')) | ||
// load from .env.vault file | ||
if (process.env.DOTENV_KEY && process.env.DOTENV_KEY.length > 0) { | ||
const filepath = helpers.resolvePath('.env.vault') | ||
if (!fs.existsSync(filepath)) { | ||
logger.error(`you set DOTENV_KEY but your .env.vault file is missing: ${filepath}`) | ||
} else { | ||
logger.verbose(`loading env from encrypted ${filepath}`) | ||
try { | ||
logger.debug(`reading encrypted env from ${filepath}`) | ||
const src = fs.readFileSync(filepath, { encoding: ENCODING }) | ||
logger.debug(`parsing encrypted env from ${filepath}`) | ||
const parsedVault = main.parse(src) | ||
logger.debug(`decrypting encrypted env from ${filepath}`) | ||
// handle scenario for comma separated keys - for use with key rotation | ||
// example: DOTENV_KEY="dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=prod,dotenv://:key_7890@dotenv.org/vault/.env.vault?environment=prod" | ||
const dotenvKeys = process.env.DOTENV_KEY.split(',') | ||
const length = dotenvKeys.length | ||
let decrypted | ||
for (let i = 0; i < length; i++) { | ||
try { | ||
// Get full dotenvKey | ||
const dotenvKey = dotenvKeys[i].trim() | ||
const key = helpers._parseEncryptionKeyFromDotenvKey(dotenvKey) | ||
const ciphertext = helpers._parseCipherTextFromDotenvKeyAndParsedVault(dotenvKey, parsedVault) | ||
// Decrypt | ||
decrypted = main.decrypt(ciphertext, key) | ||
break | ||
} catch (error) { | ||
// last key | ||
if (i + 1 >= length) { | ||
throw error | ||
} | ||
// try next key | ||
} | ||
} | ||
logger.debug(decrypted) | ||
logger.debug(`parsing decrypted env from ${filepath}`) | ||
const parsed = main.parse(decrypted) | ||
logger.debug(`writing decrypted env from ${filepath}`) | ||
const result = main.write(process.env, parsed, options.overload) | ||
logger.info(`loading env (${result.written.size}) from encrypted .env.vault`) | ||
} catch (e) { | ||
logger.error(e) | ||
} | ||
} | ||
} else { | ||
// convert to array if needed | ||
let optionEnvFile = options.envFile | ||
if (!Array.isArray(optionEnvFile)) { | ||
optionEnvFile = [optionEnvFile] | ||
} | ||
const readableFilepaths = new Set() | ||
const written = new Set() | ||
for (const envFilepath of optionEnvFile) { | ||
const filepath = helpers.resolvePath(envFilepath) | ||
logger.verbose(`loading env from ${filepath}`) | ||
try { | ||
logger.debug(`reading env from ${filepath}`) | ||
const src = fs.readFileSync(filepath, { encoding: ENCODING }) | ||
logger.debug(`parsing env from ${filepath}`) | ||
const parsed = main.parse(src) | ||
logger.debug(`writing env from ${filepath}`) | ||
const result = main.write(process.env, parsed, options.overload) | ||
readableFilepaths.add(envFilepath) | ||
result.written.forEach(key => written.add(key)) | ||
} catch (e) { | ||
logger.warn(e) | ||
} | ||
} | ||
if (readableFilepaths.size > 0) { | ||
logger.info(`loading env (${written.size}) from ${[...readableFilepaths]}`) | ||
} | ||
} | ||
// Extract command and arguments after '--' | ||
const commandIndex = process.argv.indexOf('--') | ||
if (commandIndex === -1 || commandIndex === process.argv.length - 1) { | ||
logger.error('missing command after [dotenvx run --]') | ||
logger.error('') | ||
logger.error(' get help: [dotenvx help run]') | ||
logger.error(' or try: [dotenvx run -- npm run dev]') | ||
process.exit(1) | ||
} else { | ||
const subCommand = process.argv.slice(commandIndex + 1) | ||
await helpers.executeCommand(subCommand, process.env) | ||
} | ||
}) | ||
// dotenvx encrypt | ||
@@ -181,146 +72,7 @@ program.command('encrypt') | ||
.option('-f, --env-file <paths...>', 'path(s) to your env file(s)', helpers.findEnvFiles('./')) | ||
.action(function () { | ||
const options = this.opts() | ||
logger.debug('configuring options') | ||
logger.debug(options) | ||
.action(require('./actions/encrypt')) | ||
let optionEnvFile = options.envFile | ||
if (!Array.isArray(optionEnvFile)) { | ||
optionEnvFile = [optionEnvFile] | ||
} | ||
// dotenvx hub | ||
program.addCommand(require('./commands/hub')) | ||
const addedKeys = new Set() | ||
const addedVaults = new Set() | ||
const addedEnvFilepaths = new Set() | ||
try { | ||
logger.verbose(`generating .env.keys from ${optionEnvFile}`) | ||
const dotenvKeys = (main.configDotenv({ path: '.env.keys' }).parsed || {}) | ||
for (const envFilepath of optionEnvFile) { | ||
const filepath = helpers.resolvePath(envFilepath) | ||
if (!fs.existsSync(filepath)) { | ||
throw new Error(`file does not exist: ${filepath}`) | ||
} | ||
const environment = helpers.guessEnvironment(filepath) | ||
const key = `DOTENV_KEY_${environment.toUpperCase()}` | ||
let value = dotenvKeys[key] | ||
// first time seeing new DOTENV_KEY_${environment} | ||
if (!value || value.length === 0) { | ||
logger.verbose(`generating ${key}`) | ||
value = helpers.generateDotenvKey(environment) | ||
logger.debug(`generating ${key} as ${value}`) | ||
dotenvKeys[key] = value | ||
addedKeys.add(key) // for info logging to user | ||
} else { | ||
logger.verbose(`existing ${key}`) | ||
logger.debug(`existing ${key} as ${value}`) | ||
} | ||
} | ||
let keysData = `#/!!!!!!!!!!!!!!!!!!!.env.keys!!!!!!!!!!!!!!!!!!!!!!/ | ||
#/ DOTENV_KEYs. DO NOT commit to source control / | ||
#/ [how it works](https://dotenv.org/env-keys) / | ||
#/--------------------------------------------------/\n` | ||
for (const key in dotenvKeys) { | ||
const value = dotenvKeys[key] | ||
keysData += `${key}="${value}"\n` | ||
} | ||
fs.writeFileSync('.env.keys', keysData) | ||
} catch (e) { | ||
logger.error(e) | ||
process.exit(1) | ||
} | ||
// used later in logging to user | ||
const dotenvKeys = (main.configDotenv({ path: '.env.keys' }).parsed || {}) | ||
try { | ||
logger.verbose(`generating .env.vault from ${optionEnvFile}`) | ||
const dotenvVaults = (main.configDotenv({ path: '.env.vault' }).parsed || {}) | ||
for (const envFilepath of optionEnvFile) { | ||
const filepath = helpers.resolvePath(envFilepath) | ||
const environment = helpers.guessEnvironment(filepath) | ||
const vault = `DOTENV_VAULT_${environment.toUpperCase()}` | ||
let ciphertext = dotenvVaults[vault] | ||
const dotenvKey = dotenvKeys[`DOTENV_KEY_${environment.toUpperCase()}`] | ||
if (!ciphertext || ciphertext.length === 0 || helpers.changed(ciphertext, dotenvKey, filepath, ENCODING)) { | ||
logger.verbose(`encrypting ${vault}`) | ||
ciphertext = helpers.encryptFile(filepath, dotenvKey, ENCODING) | ||
logger.verbose(`encrypting ${vault} as ${ciphertext}`) | ||
dotenvVaults[vault] = ciphertext | ||
addedVaults.add(vault) // for info logging to user | ||
addedEnvFilepaths.add(envFilepath) // for info logging to user | ||
} else { | ||
logger.verbose(`existing ${vault}`) | ||
logger.debug(`existing ${vault} as ${ciphertext}`) | ||
} | ||
} | ||
let vaultData = `#/-------------------.env.vault---------------------/ | ||
#/ cloud-agnostic vaulting standard / | ||
#/ [how it works](https://dotenv.org/env-vault) / | ||
#/--------------------------------------------------/\n\n` | ||
for (const vault in dotenvVaults) { | ||
const value = dotenvVaults[vault] | ||
const environment = vault.replace('DOTENV_VAULT_', '').toLowerCase() | ||
vaultData += `# ${environment}\n` | ||
vaultData += `${vault}="${value}"\n\n` | ||
} | ||
fs.writeFileSync('.env.vault', vaultData) | ||
} catch (e) { | ||
logger.error(e) | ||
process.exit(1) | ||
} | ||
if (addedEnvFilepaths.size > 0) { | ||
logger.info(`encrypted to .env.vault (${[...addedEnvFilepaths]})`) | ||
} else { | ||
logger.info(`no changes (${optionEnvFile})`) | ||
} | ||
if (addedKeys.size > 0) { | ||
logger.info(`${helpers.pluralize('key', addedKeys.size)} added to .env.keys (${[...addedKeys]})`) | ||
} | ||
if (addedVaults.size > 0) { | ||
const DOTENV_VAULT_X = [...addedVaults][addedVaults.size - 1] | ||
const DOTENV_KEY_X = DOTENV_VAULT_X.replace('_VAULT_', '_KEY_') | ||
const tryKey = dotenvKeys[DOTENV_KEY_X] || '<dotenv_key_environment>' | ||
logger.info('') | ||
logger.info('next, try it:') | ||
logger.info('') | ||
logger.info(` [DOTENV_KEY='${tryKey}' dotenvx run -- your-cmd]`) | ||
} | ||
logger.verbose('') | ||
logger.verbose('next:') | ||
logger.verbose('') | ||
logger.verbose(' 1. commit .env.vault safely to code') | ||
logger.verbose(' 2. set DOTENV_KEY on server (or ci)') | ||
logger.verbose(' 3. push your code') | ||
logger.verbose('') | ||
logger.verbose('tips:') | ||
logger.verbose('') | ||
logger.verbose(' * .env.keys file holds your decryption DOTENV_KEYs') | ||
logger.verbose(' * DO NOT commit .env.keys to code') | ||
logger.verbose(' * share .env.keys file over secure channels only') | ||
}) | ||
program.parse(process.argv) |
@@ -19,3 +19,3 @@ const run = function () { | ||
$ dotenvx run -- node index.js | ||
[dotenvx][info] loading env (1) from .env | ||
[dotenvx] injecting env (1) from .env | ||
Hello World | ||
@@ -42,7 +42,7 @@ \`\`\` | ||
$ dotenvx encrypt | ||
[dotenvx][info] encrypted to .env.vault (.env,.env.production) | ||
[dotenvx][info] keys added to .env.keys (DOTENV_KEY_PRODUCTION,DOTENV_KEY_PRODUCTION) | ||
encrypted to .env.vault (.env,.env.production) | ||
keys added to .env.keys (DOTENV_KEY_PRODUCTION,DOTENV_KEY_PRODUCTION) | ||
$ DOTENV_KEY='<dotenv_key_production>' dotenvx run -- node index.js | ||
[dotenvx][info] loading env (1) from encrypted .env.vault | ||
[dotenvx] injecting env (1) from encrypted .env.vault | ||
Hello production | ||
@@ -49,0 +49,0 @@ \`\`\` |
@@ -5,2 +5,3 @@ const fs = require('fs') | ||
const crypto = require('crypto') | ||
const { execSync } = require('child_process') | ||
const xxhash = require('xxhashjs') | ||
@@ -17,2 +18,6 @@ | ||
const sleep = function (ms) { | ||
return new Promise(resolve => setTimeout(resolve, ms)) | ||
} | ||
// resolve path based on current running process location | ||
@@ -210,3 +215,32 @@ const resolvePath = function (filepath) { | ||
const formatCode = function (str) { | ||
const parts = [] | ||
for (let i = 0; i < str.length; i += 4) { | ||
parts.push(str.substring(i, i + 4)) | ||
} | ||
return parts.join('-') | ||
} | ||
const getRemoteOriginUrl = function () { | ||
try { | ||
const url = execSync('git remote get-url origin 2> /dev/null').toString().trim() | ||
return url | ||
} catch (_error) { | ||
return null | ||
} | ||
} | ||
const extractUsernameName = function (url) { | ||
// Removing the protocol part and splitting by slashes and colons | ||
// Removing the protocol part and .git suffix, then splitting by slashes and colons | ||
const parts = url.replace(/(^\w+:|^)\/\//, '').replace(/\.git$/, '').split(/[/:]/) | ||
// Extract the 'username/repository' part | ||
return parts.slice(-2).join('/') | ||
} | ||
module.exports = { | ||
sleep, | ||
resolvePath, | ||
@@ -222,4 +256,7 @@ executeCommand, | ||
hash, | ||
formatCode, | ||
getRemoteOriginUrl, | ||
extractUsernameName, | ||
_parseEncryptionKeyFromDotenvKey, | ||
_parseCipherTextFromDotenvKeyAndParsedVault | ||
} |
@@ -24,8 +24,8 @@ const logger = require('./../shared/logger') | ||
const write = function (processEnv = {}, parsed = {}, overload = false) { | ||
const inject = function (processEnv = {}, parsed = {}, overload = false) { | ||
if (typeof parsed !== 'object') { | ||
throw new Error('OBJECT_REQUIRED: Please check the parsed argument being passed to write') | ||
throw new Error('OBJECT_REQUIRED: Please check the parsed argument being passed to inject') | ||
} | ||
const written = new Set() | ||
const injected = new Set() | ||
const preExisting = new Set() | ||
@@ -38,3 +38,3 @@ | ||
processEnv[key] = parsed[key] | ||
written.add(key) | ||
injected.add(key) | ||
@@ -46,8 +46,8 @@ logger.verbose(`${key} set`) | ||
logger.verbose(`${key} pre-exists`) | ||
logger.debug(`${key} pre-exists as ${processEnv[key]}`) | ||
logger.verbose(`${key} pre-exists (protip: use --overload to override)`) | ||
logger.debug(`${key} pre-exists as ${processEnv[key]} (protip: use --overload to override)`) | ||
} | ||
} else { | ||
processEnv[key] = parsed[key] | ||
written.add(key) | ||
injected.add(key) | ||
@@ -60,3 +60,3 @@ logger.verbose(`${key} set`) | ||
return { | ||
written, | ||
injected, | ||
preExisting | ||
@@ -71,3 +71,3 @@ } | ||
parse, | ||
write | ||
inject | ||
} |
const winston = require('winston') | ||
const chalk = require('chalk') | ||
@@ -10,6 +11,56 @@ const printf = winston.format.printf | ||
const levels = { | ||
error: 0, | ||
warn: 1, | ||
success: 2, | ||
successv: 2, | ||
info: 2, | ||
help: 2, | ||
help2: 2, | ||
blank: 2, | ||
http: 3, | ||
verbose: 4, | ||
debug: 5, | ||
silly: 6 | ||
} | ||
const error = chalk.bold.red | ||
const warn = chalk.keyword('orangered') | ||
const success = chalk.keyword('green') | ||
const successv = chalk.keyword('olive') // yellow-ish tint that 'looks' like dotenv | ||
const help = chalk.keyword('blue') | ||
const help2 = chalk.keyword('gray') | ||
const http = chalk.keyword('green') | ||
const verbose = chalk.keyword('plum') | ||
const debug = chalk.keyword('plum') | ||
const dotenvxFormat = printf(({ level, message, label, timestamp }) => { | ||
const formattedMessage = typeof message === 'object' ? JSON.stringify(message) : message | ||
return `[dotenvx@${packageJson.version}][${level.toLowerCase()}] ${formattedMessage}` | ||
switch (level.toLowerCase()) { | ||
case 'error': | ||
return error(formattedMessage) | ||
case 'warn': | ||
return warn(formattedMessage) | ||
case 'success': | ||
return success(formattedMessage) | ||
case 'successv': // success with 'version' | ||
return successv(`[dotenvx@${packageJson.version}] ${formattedMessage}`) | ||
case 'info': | ||
return formattedMessage | ||
case 'help': | ||
return help(formattedMessage) | ||
case 'help2': | ||
return help2(formattedMessage) | ||
case 'http': | ||
return http(formattedMessage) | ||
case 'verbose': | ||
return verbose(formattedMessage) | ||
case 'debug': | ||
return debug(formattedMessage) | ||
case 'blank': // custom | ||
return formattedMessage | ||
default: // handle uncaught | ||
return formattedMessage | ||
} | ||
}) | ||
@@ -19,2 +70,3 @@ | ||
level: 'info', | ||
levels, | ||
format: combine( | ||
@@ -21,0 +73,0 @@ dotenvxFormat |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
45984
50.46%19
111.11%1057
71.59%425
2.41%14
180%14
16.67%3
Infinity%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added