diff --git a/README.md b/README.md index ac9cdef..986ad80 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Crates.io Downloads](https://img.shields.io/crates/d/dotenv-cli?logo=Rust&color=blue)](https://crates.io/crates/dotenv-cli) [![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/mikegarde/dotenv-cli/total?logo=github&color=blue)](https://github.com/MikeGarde/dotenv-cli/releases) -A simple way to retrieve, update, or delete .env variables directly from the command line. +A simple way to retrieve, update, delete, or inject .env variables directly from the command line. ## Install @@ -76,6 +76,27 @@ Delete a value from a .env file: dotenv --delete ``` +### Running a Command + +Load the variables from a `.env` file and run a command with them injected into +its environment. Everything after `--` is treated as the command to run: + +```shell +dotenv -- npm run start +``` + +The child process inherits the current environment merged with the values from +the `.env` file. Variables already present in the environment take precedence +over values from the file, so an existing `export FOO=...` is not clobbered. + +This respects `--file` and the `DOTENV_FILE` environment variable, so you can run against any file: + +```shell +dotenv --file .env.production -- ./deploy.sh +``` + +The command's exit code is passed through, making it safe to chain in scripts and CI pipelines. + ### Validating a File Check that a .env file can be parsed without errors: @@ -88,8 +109,11 @@ dotenv --validate --file .env.example ### RSA Key Pair -1. **Private Key:** Generate a new key using the `openssl` command. The private key is then stored in the .env file under the variable `RSA_KEY`. -2. **Public Key** The `dotenv` command, with the `--multiline` flag, retrieves the stored private key and pipes it back to openssl. `openssl` then generates a corresponding public key. This public key is stored in the `.env` file under the variable `RSA_PUB`. +1. **Private Key:** Generate a new key using the `openssl` command. The private key is then + stored in the .env file under the variable `RSA_KEY`. +2. **Public Key** The `dotenv` command, with the `--multiline` flag, retrieves the stored private + key and pipes it back to openssl. `openssl` then generates a corresponding public key. This + public key is stored in the `.env` file under the variable `RSA_PUB`. ```shell openssl genpkey -algorithm RSA -outform PEM -pkeyopt rsa_keygen_bits:2048 2>/dev/null | dotenv RSA_KEY --set - @@ -98,7 +122,9 @@ dotenv RSA_KEY -m | openssl rsa -pubout 2>/dev/null | dotenv RSA_PUB --set - ### App Version -This demonstrates two methods for updating the `APP_VERSION` in your `.env` file. The `sed` command is versatile and powerful, allowing for complex text manipulations. On the other hand, `dotenv` provides a more readable and straightforward syntax. +This demonstrates two methods for updating the `APP_VERSION` in your `.env` file. The `sed` +command is versatile and powerful, allowing for complex text manipulations. On the other +hand, `dotenv` provides a more readable and straightforward syntax. ```shell NEW_VERSION=3.22.1 @@ -131,8 +157,8 @@ $ dotenv | jq 'to_entries | map(select(.key | startswith("DB_")))[] | "\(.key)=\ ### JSON -By default multiple keys are returned as a JSON object. To return a single key as a JSON object, use the `--json` flag. -To not return a JSON object, use the `--no-json` flag. +By default, multiple keys are returned as a JSON object. To return a single key as a +JSON object, use the `--json` flag. To not return a JSON object, use the `--no-json` flag. Return a .env file as JSON: @@ -163,13 +189,11 @@ KUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm ### Using DOTENV_FILE Environment Variable -You can define the `DOTENV_FILE` environment variable in your shell or script to specify the `.env` file to use, instead -of passing the `--file` option every time. +You can define the `DOTENV_FILE` environment variable in your shell or script to specify the +`.env` file to use, instead of passing the `--file` option every time. If the `--file` option +is provided, it will override the `DOTENV_FILE` environment variable. ```shell export DOTENV_FILE=.env.example dotenv ``` - -This will use the `.env.example` file automatically. If the `--file` option is provided, it will override the -`DOTENV_FILE` environment variable. diff --git a/Taskfile.yaml b/Taskfile.yaml index 8899c79..d57fc08 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -83,6 +83,8 @@ tasks: - task: uninstall - npm install - task: build:release + - mkdir -p vendor + - cp target/release/dotenv vendor/dotenv - npm install -g install:npm:repo: diff --git a/package.json b/package.json index a6eb127..98138f3 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.2.1", "description": "Read and update dotenv files from the cli", "bin": { - "dotenv": "./bin/dotenv" + "dotenv": "bin/dotenv" }, "type": "module", "files": [ diff --git a/src/cli.rs b/src/cli.rs index 36bf8b8..281a698 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -43,4 +43,11 @@ pub struct Cli { #[arg(short, long, action = ArgAction::SetTrue, help = "Output extra debugging")] pub debug: bool, + + #[arg( + last = true, + value_name = "command", + help = "Run a command with the .env variables injected (e.g. `dotenv -- npm run start`)" + )] + pub command: Vec, } diff --git a/src/handlers/get_value.rs b/src/handlers/get_value.rs index 3597ce4..7dbe24c 100644 --- a/src/handlers/get_value.rs +++ b/src/handlers/get_value.rs @@ -12,7 +12,7 @@ pub fn get_value(options: &Options) -> bool { let env_object = options.env_object.as_ref().unwrap(); let mut all_found = true; - let keys: Vec = if options.target_keys.is_empty() { + let keys: Vec = if options.return_all_keys { env_object.keys().cloned().collect() } else { options.target_keys.clone() diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index cc156df..e095b66 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,3 +1,4 @@ pub mod delete_key; pub mod get_value; +pub mod run_command; pub mod set_value; diff --git a/src/handlers/run_command.rs b/src/handlers/run_command.rs new file mode 100644 index 0000000..90833fe --- /dev/null +++ b/src/handlers/run_command.rs @@ -0,0 +1,43 @@ +use std::process::Command; + +use crate::env_object::EnvObject; + +pub fn run_command(env_object: &EnvObject, command: &[String], debug: bool) -> i32 { + let program = &command[0]; + let args = &command[1..]; + + let mut cmd = Command::new(program); + cmd.args(args); + + for (key, env_val) in env_object.entries.iter() { + // Don't override variables already present in the environment. + if std::env::var_os(key).is_none() { + cmd.env(key, &env_val.value); + } else if debug { + eprintln!("Skipping {} (already set in the environment)", key); + } + } + + if debug { + eprintln!("Running: {} {}", program, args.join(" ")); + } + + match cmd.status() { + Ok(status) => status.code().unwrap_or_else(|| { + // Terminated by a signal (Unix): mirror shells with 128 + signal. + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + status.signal().map(|s| 128 + s).unwrap_or(1) + } + #[cfg(not(unix))] + { + 1 + } + }), + Err(e) => { + eprintln!("dotenv: failed to run '{}': {}", program, e); + 127 + } + } +} diff --git a/src/main.rs b/src/main.rs index 8637a26..bea3830 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,6 +85,33 @@ fn run() -> Result> { return Ok(0); } + // `dotenv -- [args...]` runs a command with + // the .env variables injected into its environment. + if !cli.command.is_empty() { + if !cli.key.is_empty() || cli.set.is_some() || cli.delete || cli.json { + return Err(Box::new(RuleViolationError( + "Cannot combine a command (after `--`) with keys, --set, --delete, or --json" + .to_string(), + ))); + } + + let full_env_path_str = resolve_env_path(cli.file.clone())?; + + if cli.debug { + eprintln!("File: {}", full_env_path_str); + eprintln!("Command: {:?}", cli.command); + } + + let env_object = parse_env_file(&full_env_path_str) + .map_err(|e| Box::new(e) as Box)?; + + return Ok(handlers::run_command::run_command( + &env_object, + &cli.command, + cli.debug, + )); + } + let debug = cli.debug; let multiline = cli.multiline; let delete = cli.delete; diff --git a/tests/debug.rs b/tests/debug.rs new file mode 100644 index 0000000..492fcf6 --- /dev/null +++ b/tests/debug.rs @@ -0,0 +1,100 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::Path; + +fn bin() -> Command { + Command::cargo_bin("dotenv").unwrap() +} + +fn env_path() -> String { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + here.join("tests/.env.test").to_string_lossy().to_string() +} + +#[test] +fn debug_reports_keys_and_file() { + bin() + .arg("NAME") + .arg("--debug") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout("dotenv-cli\n") + .stderr( + predicate::str::contains("Keys: [\"NAME\"]") + .and(predicate::str::contains(".env.test")) + .and(predicate::str::contains("Options assembled")), + ); +} + +#[test] +fn debug_reports_json_defaulting_and_wildcards() { + bin() + .arg("NESTED_*") + .arg("--debug") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stderr(predicate::str::contains("Wildcard found")); +} + +#[test] +fn debug_reports_json_defaulting_for_multiple_keys() { + bin() + .arg("NAME") + .arg("EMPTY") + .arg("--debug") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stderr(predicate::str::contains( + "Key count (0 or >1) defaulting to JSON", + )); +} + +#[test] +fn debug_reports_file_during_validate() { + bin() + .arg("--validate") + .arg("--debug") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stderr(predicate::str::contains("File: ")); +} + +#[test] +fn debug_reports_command_before_running_it() { + bin() + .arg("--debug") + .arg("--file") + .arg(env_path()) + .arg("--") + .arg("true") + .assert() + .success() + .stderr( + predicate::str::contains("Command: [\"true\"]") + .and(predicate::str::contains("Running: true")), + ); +} + +#[test] +fn debug_reports_variables_skipped_because_already_set() { + bin() + .arg("--debug") + .arg("--file") + .arg(env_path()) + .env("NAME", "from-the-shell") + .arg("--") + .arg("true") + .assert() + .success() + .stderr(predicate::str::contains( + "Skipping NAME (already set in the environment)", + )); +} diff --git a/tests/run_command.rs b/tests/run_command.rs new file mode 100644 index 0000000..f662fa4 --- /dev/null +++ b/tests/run_command.rs @@ -0,0 +1,131 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::io::Write; +use tempfile::NamedTempFile; + +fn bin() -> Command { + Command::cargo_bin("dotenv").unwrap() +} + +fn env_file(content: &str) -> NamedTempFile { + let mut tmp = NamedTempFile::new().unwrap(); + tmp.write_all(content.as_bytes()).unwrap(); + tmp +} + +#[test] +fn injects_variables_into_command() { + let env = env_file("GREETING=hello\nNAME=world\n"); + bin() + .arg("--file") + .arg(env.path()) + .arg("--") + .arg("sh") + .arg("-c") + .arg("printf '%s %s' \"$GREETING\" \"$NAME\"") + .assert() + .success() + .stdout("hello world"); +} + +#[test] +fn resolves_nested_variables() { + let env = env_file("GREETING=hello\nNAME=world\nNESTED=${GREETING}-${NAME}\n"); + bin() + .arg("--file") + .arg(env.path()) + .arg("--") + .arg("sh") + .arg("-c") + .arg("printf '%s' \"$NESTED\"") + .assert() + .success() + .stdout("hello-world"); +} + +#[test] +fn passes_through_exit_code() { + let env = env_file("FOO=bar\n"); + bin() + .arg("--file") + .arg(env.path()) + .arg("--") + .arg("sh") + .arg("-c") + .arg("exit 3") + .assert() + .code(3); +} + +#[cfg(unix)] +#[test] +fn signal_terminated_command_exits_128_plus_signal() { + let env = env_file("FOO=bar\n"); + // SIGTERM is 15, so shells report 143. + bin() + .arg("--file") + .arg(env.path()) + .arg("--") + .arg("bash") + .arg("-c") + .arg("kill -TERM $$; sleep 5") + .assert() + .code(143); +} + +#[test] +fn existing_environment_takes_precedence() { + let env = env_file("GREETING=fromfile\n"); + bin() + .arg("--file") + .arg(env.path()) + .env("GREETING", "fromshell") + .arg("--") + .arg("sh") + .arg("-c") + .arg("printf '%s' \"$GREETING\"") + .assert() + .success() + .stdout("fromshell"); +} + +#[test] +fn missing_command_binary_exits_127() { + let env = env_file("FOO=bar\n"); + bin() + .arg("--file") + .arg(env.path()) + .arg("--") + .arg("this_command_does_not_exist_xyz") + .assert() + .code(127) + .stderr(predicate::str::contains("failed to run")); +} + +#[test] +fn missing_env_file_reports_error() { + bin() + .arg("--file") + .arg("non-existent.env") + .arg("--") + .arg("echo") + .arg("hi") + .assert() + .failure() + .stderr(predicate::str::contains("File not found")); +} + +#[test] +fn rejects_command_combined_with_key() { + let env = env_file("FOO=bar\n"); + bin() + .arg("--file") + .arg(env.path()) + .arg("KEY") + .arg("--") + .arg("echo") + .arg("hi") + .assert() + .failure() + .stderr(predicate::str::contains("Cannot combine a command")); +} diff --git a/tests/wildcard.rs b/tests/wildcard.rs new file mode 100644 index 0000000..b7847c5 --- /dev/null +++ b/tests/wildcard.rs @@ -0,0 +1,89 @@ +use assert_cmd::Command; +use std::path::Path; + +fn bin() -> Command { + Command::cargo_bin("dotenv").unwrap() +} + +fn env_path() -> String { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + here.join("tests/.env.test").to_string_lossy().to_string() +} + +fn keys_for(pattern: &str) -> Vec { + let output = bin() + .arg(pattern) + .arg("--file") + .arg(env_path()) + .assert() + .success() + .get_output() + .stdout + .clone(); + let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); + value.as_object().unwrap().keys().cloned().collect() +} + +#[test] +fn trailing_wildcard_matches_by_prefix() { + assert_eq!(keys_for("NESTED_*"), vec!["NESTED_VAR1", "NESTED_VAR2"]); +} + +#[test] +fn leading_wildcard_matches_by_suffix() { + assert_eq!( + keys_for("*_MULTI"), + vec!["DOUBLE_MULTI", "SINGLE_MULTI", "CORRECT_MULTI"] + ); +} + +#[test] +fn wildcard_on_both_sides_matches_the_middle() { + assert_eq!(keys_for("*_MULTI_*"), vec!["LIST_MULTI_LINE"]); +} + +#[test] +fn wildcard_output_resolves_nested_variables() { + bin() + .arg("NESTED_VAR2*") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout("{\"NESTED_VAR2\":\"Hello World\"}\n"); +} + +#[test] +fn wildcard_forces_json_even_for_a_single_match() { + bin() + .arg("NAM*") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout("{\"NAME\":\"dotenv-cli\"}\n"); +} + +#[test] +fn wildcard_matching_nothing_returns_an_empty_object_not_the_whole_file() { + bin() + .arg("ZZZ_*") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout("{}\n"); +} + +#[test] +fn no_json_disables_wildcard_expansion() { + // With --no-json the pattern is treated as a literal key name, which does + // not exist, so the lookup fails. + bin() + .arg("NESTED_*") + .arg("--no-json") + .arg("--file") + .arg(env_path()) + .assert() + .failure(); +}