From 614d25d6016ce7778195c96ee622e42a48253de6 Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Thu, 11 Jun 2026 15:42:50 -0700 Subject: [PATCH] exploring addition of cli to dotenv --- README.md | 15 +++ cli.js | 149 ++++++++++++++++++++++++++++ package-lock.json | 3 + package.json | 3 + tests/test-cli.js | 245 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100755 cli.js create mode 100644 tests/test-cli.js 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() +})