From 825c8dec73aab1fbea3961b5181e84fcf9b07735 Mon Sep 17 00:00:00 2001 From: Bosco Domingo <boscodomingob@gmail.com> Date: Sun, 19 May 2024 20:22:24 +0200 Subject: [PATCH] src: add `--env-file-if-exists` flag Fixes: https://github.com/nodejs/node/issues/50993 Refs: https://github.com/nodejs/node/issues/51451 test: remove unnecessary comment src: conform to style guidelines src: change flag to `--env-file-optional` test: revert automatic linter changes doc: fix typos src: change flag to `--env-file-if-exists` src: refactor `env_file_data` and `GetEnvFileDataFromArgs` test: clean up tests src: print error when file not found test: remove unnecessary extras --- doc/api/cli.md | 16 ++++++++ src/node.cc | 18 ++++++--- src/node_dotenv.cc | 48 ++++++++++++++-------- src/node_dotenv.h | 6 ++- src/node_options.cc | 4 ++ src/node_options.h | 1 + test/parallel/test-dotenv-edge-cases.js | 54 ++++++++++++++++++++----- 7 files changed, 115 insertions(+), 32 deletions(-) diff --git a/doc/api/cli.md b/doc/api/cli.md index 01efb97e31be23..8de2f464c666ce 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -828,6 +828,8 @@ in the file, the value from the environment takes precedence. You can pass multiple `--env-file` arguments. Subsequent files override pre-existing variables defined in previous files. +An error is thrown if the file does not exist. + ```bash node --env-file=.env --env-file=.development.env index.js ``` @@ -867,6 +869,9 @@ Export keyword before a key is ignored: export USERNAME="nodejs" # will result in `nodejs` as the value. ``` +If you want to load environment variables from a file that may not exist, you +can use the [`--env-file-if-exists`][] flag instead. + ### `-e`, `--eval "script"` <!-- YAML @@ -1761,6 +1766,15 @@ is being linked to Node.js. Sharing the OpenSSL configuration may have unwanted implications and it is recommended to use a configuration section specific to Node.js which is `nodejs_conf` and is default when this option is not used. +### `--env-file-if-exists=config` + +<!-- YAML +added: REPLACEME +--> + +Behavior is the same as [`--env-file`][], but an error is not thrown if the file +does not exist. + ### `--pending-deprecation` <!-- YAML @@ -3548,6 +3562,8 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [`--build-snapshot`]: #--build-snapshot [`--cpu-prof-dir`]: #--cpu-prof-dir [`--diagnostic-dir`]: #--diagnostic-dirdirectory +[`--env-file-if-exists`]: #--env-file-if-existsconfig +[`--env-file`]: #--env-fileconfig [`--experimental-default-type=module`]: #--experimental-default-typetype [`--experimental-sea-config`]: single-executable-applications.md#generating-single-executable-preparation-blobs [`--experimental-strip-types`]: #--experimental-strip-types diff --git a/src/node.cc b/src/node.cc index 50d1d17942194a..b9a92ade0b3ca5 100644 --- a/src/node.cc +++ b/src/node.cc @@ -854,20 +854,26 @@ static ExitCode InitializeNodeWithArgsInternal( HandleEnvOptions(per_process::cli_options->per_isolate->per_env); std::string node_options; - auto file_paths = node::Dotenv::GetPathFromArgs(*argv); + auto env_files = node::Dotenv::GetDataFromArgs(*argv); - if (!file_paths.empty()) { + if (!env_files.empty()) { CHECK(!per_process::v8_initialized); - for (const auto& file_path : file_paths) { - switch (per_process::dotenv_file.ParsePath(file_path)) { + for (const auto& file_data : env_files) { + switch (per_process::dotenv_file.ParsePath(file_data.path)) { case Dotenv::ParseResult::Valid: break; case Dotenv::ParseResult::InvalidContent: - errors->push_back(file_path + ": invalid format"); + errors->push_back(file_data.path + ": invalid format"); break; case Dotenv::ParseResult::FileError: - errors->push_back(file_path + ": not found"); + if (file_data.is_optional) { + fprintf(stderr, + "%s not found. Continuing without it.\n", + file_data.path.c_str()); + continue; + } + errors->push_back(file_data.path + ": not found"); break; default: UNREACHABLE(); diff --git a/src/node_dotenv.cc b/src/node_dotenv.cc index 1cb57fcaea2628..f594df875d7a0c 100644 --- a/src/node_dotenv.cc +++ b/src/node_dotenv.cc @@ -11,36 +11,52 @@ using v8::NewStringType; using v8::Object; using v8::String; -std::vector<std::string> Dotenv::GetPathFromArgs( +std::vector<Dotenv::env_file_data> Dotenv::GetDataFromArgs( const std::vector<std::string>& args) { + const std::string_view optional_env_file_flag = "--env-file-if-exists"; + const auto find_match = [](const std::string& arg) { - return arg == "--" || arg == "--env-file" || arg.starts_with("--env-file="); + return arg == "--" || arg == "--env-file" || + arg.starts_with("--env-file=") || arg == "--env-file-if-exists" || + arg.starts_with("--env-file-if-exists="); }; - std::vector<std::string> paths; - auto path = std::find_if(args.begin(), args.end(), find_match); - while (path != args.end()) { - if (*path == "--") { - return paths; + std::vector<Dotenv::env_file_data> env_files; + // This will be an iterator, pointing to args.end() if no matches are found + auto matched_arg = std::find_if(args.begin(), args.end(), find_match); + + while (matched_arg != args.end()) { + if (*matched_arg == "--") { + return env_files; } - auto equal_char = path->find('='); - if (equal_char != std::string::npos) { - paths.push_back(path->substr(equal_char + 1)); + auto equal_char_index = matched_arg->find('='); + + if (equal_char_index != std::string::npos) { + // `--env-file=path` + auto flag = matched_arg->substr(0, equal_char_index); + auto file_path = matched_arg->substr(equal_char_index + 1); + + struct env_file_data env_file_data = { + file_path, flag.starts_with(optional_env_file_flag)}; + env_files.push_back(env_file_data); } else { - auto next_path = std::next(path); + // `--env-file path` + auto file_path = std::next(matched_arg); - if (next_path == args.end()) { - return paths; + if (file_path == args.end()) { + return env_files; } - paths.push_back(*next_path); + struct env_file_data env_file_data = { + *file_path, matched_arg->starts_with(optional_env_file_flag)}; + env_files.push_back(env_file_data); } - path = std::find_if(++path, args.end(), find_match); + matched_arg = std::find_if(++matched_arg, args.end(), find_match); } - return paths; + return env_files; } void Dotenv::SetEnvironment(node::Environment* env) { diff --git a/src/node_dotenv.h b/src/node_dotenv.h index ef9ee54a9e75ce..d508b13fc5db74 100644 --- a/src/node_dotenv.h +++ b/src/node_dotenv.h @@ -13,6 +13,10 @@ namespace node { class Dotenv { public: enum ParseResult { Valid, FileError, InvalidContent }; + struct env_file_data { + std::string path; + bool is_optional; + }; Dotenv() = default; Dotenv(const Dotenv& d) = delete; @@ -27,7 +31,7 @@ class Dotenv { void SetEnvironment(Environment* env); v8::Local<v8::Object> ToObject(Environment* env) const; - static std::vector<std::string> GetPathFromArgs( + static std::vector<env_file_data> GetDataFromArgs( const std::vector<std::string>& args); private: diff --git a/src/node_options.cc b/src/node_options.cc index cc290661ec3b76..2ca6652538b30b 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -640,6 +640,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "set environment variables from supplied file", &EnvironmentOptions::env_file); Implies("--env-file", "[has_env_file_string]"); + AddOption("--env-file-if-exists", + "set environment variables from supplied file", + &EnvironmentOptions::optional_env_file); + Implies("--env-file-if-exists", "[has_env_file_string]"); AddOption("--test", "launch test runner on startup", &EnvironmentOptions::test_runner); diff --git a/src/node_options.h b/src/node_options.h index 5f186ae86f0bcc..b521ca76185512 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -177,6 +177,7 @@ class EnvironmentOptions : public Options { std::string redirect_warnings; std::string diagnostic_dir; std::string env_file; + std::string optional_env_file; bool has_env_file_string = false; bool test_runner = false; uint64_t test_runner_concurrency = 0; diff --git a/test/parallel/test-dotenv-edge-cases.js b/test/parallel/test-dotenv-edge-cases.js index 5528d0b47fecb1..30fd9d20618ba0 100644 --- a/test/parallel/test-dotenv-edge-cases.js +++ b/test/parallel/test-dotenv-edge-cases.js @@ -10,30 +10,53 @@ const validEnvFilePath = '../fixtures/dotenv/valid.env'; const nodeOptionsEnvFilePath = '../fixtures/dotenv/node-options.env'; describe('.env supports edge cases', () => { - - it('supports multiple declarations', async () => { - // process.env.BASIC is equal to `basic` because the second .env file overrides it. + it('supports multiple declarations, including optional ones', async () => { const code = ` const assert = require('assert'); assert.strictEqual(process.env.BASIC, 'basic'); assert.strictEqual(process.env.NODE_NO_WARNINGS, '1'); `.trim(); + const children = await Promise.all(Array.from({ length: 4 }, (_, i) => + common.spawnPromisified( + process.execPath, + [ + // Bitwise AND to create all 4 possible combinations: + // i & 0b01 is truthy when i has value 0bx1 (i.e. 0b01 (1) and 0b11 (3)), falsy otherwise. + // i & 0b10 is truthy when i has value 0b1x (i.e. 0b10 (2) and 0b11 (3)), falsy otherwise. + `${i & 0b01 ? '--env-file' : '--env-file-if-exists'}=${nodeOptionsEnvFilePath}`, + `${i & 0b10 ? '--env-file' : '--env-file-if-exists'}=${validEnvFilePath}`, + '--eval', code, + ], + { cwd: __dirname }, + ))); + assert.deepStrictEqual(children, Array.from({ length: 4 }, () => ({ + code: 0, + signal: null, + stdout: '', + stderr: '', + }))); + }); + + it('supports absolute paths', async () => { + const code = ` + require('assert').strictEqual(process.env.BASIC, 'basic'); + `.trim(); const child = await common.spawnPromisified( process.execPath, - [ `--env-file=${nodeOptionsEnvFilePath}`, `--env-file=${validEnvFilePath}`, '--eval', code ], - { cwd: __dirname }, + [ `--env-file=${path.resolve(__dirname, validEnvFilePath)}`, '--eval', code ], ); assert.strictEqual(child.stderr, ''); assert.strictEqual(child.code, 0); }); - it('supports absolute paths', async () => { + it('supports a space instead of \'=\' for the flag ', async () => { const code = ` require('assert').strictEqual(process.env.BASIC, 'basic'); `.trim(); const child = await common.spawnPromisified( process.execPath, - [ `--env-file=${path.resolve(__dirname, validEnvFilePath)}`, '--eval', code ], + [ '--env-file', validEnvFilePath, '--eval', code ], + { cwd: __dirname }, ); assert.strictEqual(child.stderr, ''); assert.strictEqual(child.code, 0); @@ -48,10 +71,23 @@ describe('.env supports edge cases', () => { [ '--env-file=.env', '--eval', code ], { cwd: __dirname }, ); - assert.notStrictEqual(child.stderr.toString(), ''); + assert.notStrictEqual(child.stderr, ''); assert.strictEqual(child.code, 9); }); + it('should handle non-existent optional .env file', async () => { + const code = ` + require('assert').strictEqual(1,1); + `.trim(); + const child = await common.spawnPromisified( + process.execPath, + ['--env-file-if-exists=.env', '--eval', code], + { cwd: __dirname }, + ); + assert.notStrictEqual(child.stderr, ''); + assert.strictEqual(child.code, 0); + }); + it('should not override existing environment variables but introduce new vars', async () => { const code = ` require('assert').strictEqual(process.env.BASIC, 'existing'); @@ -106,7 +142,7 @@ describe('.env supports edge cases', () => { '--eval', `require('assert').strictEqual(process.env.BASIC, undefined);`, '--', '--env-file', validEnvFilePath, ], - { cwd: fixtures.path('dotenv') }, + { cwd: __dirname }, ); assert.strictEqual(child.stdout, ''); assert.strictEqual(child.stderr, '');