diff --git a/README.md b/README.md
index 29d6f253..ec24a5b0 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,21 @@ const config = dotenv.parse(buf) // will return an object
console.log(typeof config, config) // object { BASIC : 'basic' }
```
+
+Run
+
+Use `dotenv run --` to run a command with environment variables from your `.env` file.
+
+```bash
+$ dotenv run -- node index.js
+```
+
+Use `-f` to select one or more `.env` files.
+
+```bash
+$ dotenv run -f .env.local -f .env -- node index.js
+```
+
Preload
diff --git a/cli.js b/cli.js
new file mode 100755
index 00000000..097b22c3
--- /dev/null
+++ b/cli.js
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+
+const fs = require('fs')
+const path = require('path')
+const cp = require('child_process')
+
+const dotenv = require('./lib/main')
+
+function printHelp () {
+ console.log([
+ 'Usage: dotenv run [--help] [-f ] -- ',
+ '',
+ 'Run a command with environment variables from a .env file.',
+ '',
+ 'Options:',
+ ' -f path to your .env file (default: .env)'
+ ].join('\n'))
+}
+
+function parseRunArgs (args) {
+ const paths = []
+ let defaultPath = true
+ let commandIndex = -1
+
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i]
+
+ if (arg === '--') {
+ commandIndex = i + 1
+ break
+ }
+
+ if (arg === '--help' || arg === '-h') {
+ return { help: true, paths, command: [] }
+ }
+
+ if (arg === '-f') {
+ const filepath = args[i + 1]
+ if (!filepath || filepath === '--') {
+ return { error: '-f requires a path' }
+ }
+
+ paths.push(filepath)
+ defaultPath = false
+ i++
+ continue
+ }
+
+ if (arg.startsWith('-f=')) {
+ const filepath = arg.slice(3)
+ if (!filepath) {
+ return { error: '-f requires a path' }
+ }
+
+ paths.push(filepath)
+ defaultPath = false
+ continue
+ }
+
+ return { error: `unknown option: ${arg}` }
+ }
+
+ const command = commandIndex === -1 ? [] : args.slice(commandIndex)
+ return {
+ paths: paths.length > 0 ? paths : ['.env'],
+ defaultPath,
+ command
+ }
+}
+
+function loadEnvFiles (paths, defaultPath) {
+ const parsedAll = {}
+
+ for (const filepath of paths) {
+ const resolvedPath = path.resolve(process.cwd(), filepath)
+ try {
+ const parsed = dotenv.parse(fs.readFileSync(resolvedPath, { encoding: 'utf8' }))
+ dotenv.populate(parsedAll, parsed)
+ } catch (e) {
+ if (!(defaultPath && e.code === 'ENOENT')) {
+ throw e
+ }
+ }
+ }
+
+ dotenv.populate(process.env, parsedAll)
+}
+
+function run (argv) {
+ const command = argv[0]
+
+ if (command === '--help' || command === '-h') {
+ printHelp()
+ return
+ }
+
+ if (command !== 'run') {
+ printHelp()
+ process.exitCode = 1
+ return
+ }
+
+ const parsed = parseRunArgs(argv.slice(1))
+ if (parsed.help) {
+ printHelp()
+ return
+ }
+
+ if (parsed.error) {
+ console.error(`dotenv: ${parsed.error}`)
+ printHelp()
+ process.exitCode = 1
+ return
+ }
+
+ if (parsed.command.length === 0) {
+ printHelp()
+ process.exitCode = 1
+ return
+ }
+
+ try {
+ loadEnvFiles(parsed.paths, parsed.defaultPath)
+ } catch (e) {
+ console.error(`dotenv: ${e.message}`)
+ process.exitCode = 1
+ return
+ }
+
+ const child = cp.spawn(parsed.command[0], parsed.command.slice(1), {
+ stdio: 'inherit',
+ shell: process.platform === 'win32'
+ })
+
+ child.on('error', function (e) {
+ console.error(`dotenv: ${e.message}`)
+ process.exitCode = 1
+ })
+
+ child.on('exit', function (exitCode, signal) {
+ if (typeof exitCode === 'number') {
+ process.exit(exitCode)
+ } else {
+ process.kill(process.pid, signal)
+ }
+ })
+}
+
+run(process.argv.slice(2))
diff --git a/package-lock.json b/package-lock.json
index f426b2bb..e6515866 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "dotenv",
"version": "17.4.2",
"license": "BSD-2-Clause",
+ "bin": {
+ "dotenv": "cli.js"
+ },
"devDependencies": {
"@types/node": "^18.11.3",
"decache": "^4.6.2",
diff --git a/package.json b/package.json
index 1efa8e64..98eb5d33 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,9 @@
"version": "17.4.2",
"description": "Loads environment variables from .env file",
"main": "lib/main.js",
+ "bin": {
+ "dotenv": "./cli.js"
+ },
"types": "lib/main.d.ts",
"exports": {
".": {
diff --git a/tests/test-cli.js b/tests/test-cli.js
new file mode 100644
index 00000000..b1794d85
--- /dev/null
+++ b/tests/test-cli.js
@@ -0,0 +1,245 @@
+const cp = require('child_process')
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+
+const t = require('tap')
+
+function spawn (args, options = {}) {
+ return cp.spawnSync(
+ process.argv[0],
+ [path.resolve(__dirname, '../cli.js')].concat(args),
+ Object.assign(
+ {},
+ {
+ cwd: path.resolve(__dirname, '..'),
+ timeout: 5000,
+ encoding: 'utf8',
+ env: Object.assign({}, process.env)
+ },
+ options
+ )
+ )
+}
+
+function removeDir (dir) {
+ if (fs.rmSync) {
+ fs.rmSync(dir, { recursive: true, force: true })
+ } else {
+ fs.rmdirSync(dir, { recursive: true })
+ }
+}
+
+t.test('dotenv run loads .env by default', ct => {
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'dotenv-run-'))
+ fs.writeFileSync(path.join(cwd, '.env'), 'BASIC=basic\n')
+
+ const result = spawn([
+ 'run',
+ '--',
+ process.argv[0],
+ '-e',
+ 'console.log(process.env.BASIC)'
+ ], {
+ cwd
+ })
+
+ removeDir(cwd)
+
+ ct.equal(result.status, 0)
+ ct.equal(result.stdout, 'basic\n')
+ ct.equal(result.stderr, '')
+ ct.end()
+})
+
+t.test('dotenv run continues when default .env is missing', ct => {
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'dotenv-run-'))
+
+ const result = spawn([
+ 'run',
+ '--',
+ process.argv[0],
+ '-e',
+ 'console.log("ok")'
+ ], {
+ cwd
+ })
+
+ removeDir(cwd)
+
+ ct.equal(result.status, 0)
+ ct.equal(result.stdout, 'ok\n')
+ ct.equal(result.stderr, '')
+ ct.end()
+})
+
+t.test('dotenv run supports -f path', ct => {
+ const result = spawn([
+ 'run',
+ '-f',
+ './tests/.env.local',
+ '--',
+ process.argv[0],
+ '-e',
+ 'console.log(process.env.BASIC)'
+ ])
+
+ ct.equal(result.status, 0)
+ ct.equal(result.stdout, 'local_basic\n')
+ ct.equal(result.stderr, '')
+ ct.end()
+})
+
+t.test('dotenv run supports -f=path', ct => {
+ const result = spawn([
+ 'run',
+ '-f=./tests/.env.local',
+ '--',
+ process.argv[0],
+ '-e',
+ 'console.log(process.env.BASIC)'
+ ])
+
+ ct.equal(result.status, 0)
+ ct.equal(result.stdout, 'local_basic\n')
+ ct.equal(result.stderr, '')
+ ct.end()
+})
+
+t.test('dotenv run supports multiple -f paths without override', ct => {
+ const result = spawn([
+ 'run',
+ '-f',
+ './tests/.env.local',
+ '-f',
+ './tests/.env',
+ '--',
+ process.argv[0],
+ '-e',
+ 'console.log(process.env.BASIC)'
+ ])
+
+ ct.equal(result.status, 0)
+ ct.equal(result.stdout, 'local_basic\n')
+ ct.equal(result.stderr, '')
+ ct.end()
+})
+
+t.test('dotenv run does not override existing environment variables', ct => {
+ const result = spawn(
+ [
+ 'run',
+ '-f',
+ './tests/.env',
+ '--',
+ process.argv[0],
+ '-e',
+ 'console.log(process.env.BASIC)'
+ ],
+ {
+ env: Object.assign({}, process.env, { BASIC: 'existing' })
+ }
+ )
+
+ ct.equal(result.status, 0)
+ ct.equal(result.stdout, 'existing\n')
+ ct.equal(result.stderr, '')
+ ct.end()
+})
+
+t.test('dotenv run ignores DOTENV_KEY vault behavior', ct => {
+ const result = spawn(
+ [
+ 'run',
+ '-f',
+ './tests/.env',
+ '--',
+ process.argv[0],
+ '-e',
+ 'console.log(process.env.BASIC)'
+ ],
+ {
+ env: Object.assign({}, process.env, { DOTENV_KEY: 'dotenv://:bad@dotenvx.com/vault/.env.vault?environment=production' })
+ }
+ )
+
+ ct.equal(result.status, 0)
+ ct.equal(result.stdout, 'basic\n')
+ ct.equal(result.stderr, '')
+ ct.end()
+})
+
+t.test('dotenv run does not expand variables', ct => {
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'dotenv-run-'))
+ fs.writeFileSync(path.join(cwd, '.env'), 'BASIC=basic\nEXPANDED=$BASIC\n')
+
+ const result = spawn([
+ 'run',
+ '--',
+ process.argv[0],
+ '-e',
+ 'console.log(process.env.EXPANDED)'
+ ], {
+ cwd
+ })
+
+ removeDir(cwd)
+
+ ct.equal(result.status, 0)
+ ct.equal(result.stdout, '$BASIC\n')
+ ct.equal(result.stderr, '')
+ ct.end()
+})
+
+t.test('dotenv run exits with child status', ct => {
+ const result = spawn([
+ 'run',
+ '--',
+ process.argv[0],
+ '-e',
+ 'process.exit(7)'
+ ])
+
+ ct.equal(result.status, 7)
+ ct.end()
+})
+
+t.test('dotenv run requires -- before command', ct => {
+ const result = spawn([
+ 'run',
+ process.argv[0],
+ '-e',
+ 'console.log("nope")'
+ ])
+
+ ct.equal(result.status, 1)
+ ct.match(result.stdout, /Usage: dotenv run/)
+ ct.equal(result.stderr, 'dotenv: unknown option: ' + process.argv[0] + '\n')
+ ct.end()
+})
+
+t.test('dotenv run requires a command', ct => {
+ const result = spawn(['run', '--'])
+
+ ct.equal(result.status, 1)
+ ct.match(result.stdout, /Usage: dotenv run/)
+ ct.equal(result.stderr, '')
+ ct.end()
+})
+
+t.test('dotenv run exits when selected env file is missing', ct => {
+ const result = spawn([
+ 'run',
+ '-f',
+ './tests/.env.missing',
+ '--',
+ process.argv[0],
+ '-e',
+ 'console.log(process.env.BASIC)'
+ ])
+
+ ct.equal(result.status, 1)
+ ct.equal(result.stdout, '')
+ ct.match(result.stderr, /ENOENT/)
+ ct.end()
+})