diff --git a/doc/api/cli.md b/doc/api/cli.md index fb764c01edd6..ff897915f385 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1337,7 +1337,8 @@ are parsed and applied. If the same variable is defined in the environment and 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. +pre-existing variables defined in previous files. Files specified in +`NODE_OPTIONS` are loaded before files specified on the command line. An error is thrown if the file does not exist. @@ -4241,6 +4242,8 @@ one is included in the list below. * `--enable-network-family-autoselection` * `--enable-source-maps` * `--entry-url` +* `--env-file-if-exists` +* `--env-file` * `--experimental-abortcontroller` * `--experimental-addon-modules` * `--experimental-bench` diff --git a/doc/node-config-schema.json b/doc/node-config-schema.json index 22523a4868f0..21638198fbc6 100644 --- a/doc/node-config-schema.json +++ b/doc/node-config-schema.json @@ -304,6 +304,36 @@ "type": "boolean", "description": "Treat the entrypoint as a URL" }, + "env-file": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ], + "description": "set environment variables from supplied file" + }, + "env-file-if-exists": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ], + "description": "set environment variables from supplied file" + }, "experimental-addon-modules": { "type": "boolean", "description": "experimental import support for addons" diff --git a/doc/node.1 b/doc/node.1 index a9ef1243fbbe..087085ac7307 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -796,7 +796,8 @@ variables which configure Node.js, such as \fBNODE_OPTIONS\fR, are parsed and applied. If the same variable is defined in the environment and in the file, the value from the environment takes precedence. You can pass multiple \fB--env-file\fR arguments. Subsequent files override -pre-existing variables defined in previous files. +pre-existing variables defined in previous files. Files specified in +\fBNODE_OPTIONS\fR are loaded before files specified on the command line. An error is thrown if the file does not exist. .Bd -literal node --env-file=.env --env-file=.development.env index.js @@ -2219,6 +2220,10 @@ one is included in the list below. .It \fB--entry-url\fR .It +\fB--env-file-if-exists\fR +.It +\fB--env-file\fR +.It \fB--experimental-abortcontroller\fR .It \fB--experimental-addon-modules\fR diff --git a/src/node.cc b/src/node.cc index d0b5310626c3..aa8f53b771bd 100644 --- a/src/node.cc +++ b/src/node.cc @@ -970,10 +970,38 @@ static ExitCode InitializeNodeWithArgsInternal( HandleEnvOptions(per_process::cli_options->per_isolate->per_env); - std::string node_options; std::string node_options_from_dotenv; auto env_files = node::Dotenv::GetDataFromArgs(*argv); +#if !defined(NODE_WITHOUT_NODE_OPTIONS) + bool should_parse_node_options = + !(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv); +#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION + if (sea::IsSingleExecutable()) { + const sea::SeaResource& sea_resource = sea::FindSingleExecutableResource(); + if (sea_resource.exec_argv_extension != sea::SeaExecArgvExtension::kEnv) { + should_parse_node_options = false; + } + } +#endif + // NODE_OPTIONS environment variable is preferred over the file one. + std::string node_options; + std::vector env_argv; + const bool node_options_from_env = + should_parse_node_options && + credentials::SafeGetenv("NODE_OPTIONS", &node_options); + if (node_options_from_env) { + env_argv = ParseNodeOptionsEnvVar(node_options, errors); + if (!errors->empty()) return ExitCode::kInvalidCommandLineArgument; + + // Command-line env files take precedence over those in NODE_OPTIONS. + auto node_options_env_files = node::Dotenv::GetDataFromArgs(env_argv); + env_files.insert(env_files.begin(), + node_options_env_files.begin(), + node_options_env_files.end()); + } +#endif + if (!env_files.empty()) { CHECK(!per_process::v8_initialized); @@ -1037,30 +1065,16 @@ static ExitCode InitializeNodeWithArgsInternal( } } - node_options = node_options_from_config + node_options_from_dotenv; - AllowEnvSources allow_env_sources( per_process::cli_options->per_isolate->per_env.get()); #if !defined(NODE_WITHOUT_NODE_OPTIONS) - bool should_parse_node_options = - !(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv); -#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION - if (sea::IsSingleExecutable()) { - const sea::SeaResource& sea_resource = sea::FindSingleExecutableResource(); - if (sea_resource.exec_argv_extension != sea::SeaExecArgvExtension::kEnv) { - should_parse_node_options = false; - } - } -#endif if (should_parse_node_options) { - // NODE_OPTIONS environment variable is preferred over the file one. - const bool node_options_from_env = - credentials::SafeGetenv("NODE_OPTIONS", &node_options); + if (!node_options_from_env) { + node_options = node_options_from_config + node_options_from_dotenv; + env_argv = ParseNodeOptionsEnvVar(node_options, errors); + } if (node_options_from_env || !node_options.empty()) { - std::vector env_argv = - ParseNodeOptionsEnvVar(node_options, errors); - if (!errors->empty()) return ExitCode::kInvalidCommandLineArgument; // [0] is expected to be the program name, fill it in from the real argv. diff --git a/src/node_options.cc b/src/node_options.cc index acd98e6b9276..6c573a4b7272 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -1111,11 +1111,13 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { AddOption("[has_env_file_string]", "", BOOL_FIELD(has_env_file_string)); AddOption("--env-file", "set environment variables from supplied file", - &EnvironmentOptions::env_file); + &EnvironmentOptions::env_file, + kAllowedInEnvvar); Implies("--env-file", "[has_env_file_string]"); AddOption("--env-file-if-exists", "set environment variables from supplied file", - &EnvironmentOptions::optional_env_file); + &EnvironmentOptions::optional_env_file, + kAllowedInEnvvar); Implies("--env-file-if-exists", "[has_env_file_string]"); AddOption("--config-file", "set config file path", diff --git a/test/parallel/test-dotenv-in-node-options.js b/test/parallel/test-dotenv-in-node-options.js new file mode 100644 index 000000000000..cd92790646b9 --- /dev/null +++ b/test/parallel/test-dotenv-in-node-options.js @@ -0,0 +1,134 @@ +'use strict'; + +const common = require('../common'); +if (process.config.variables.node_without_node_options) { + common.skip('missing NODE_OPTIONS support'); +} + +const assert = require('node:assert'); +const { writeFileSync } = require('node:fs'); +const { it } = require('node:test'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const tmpdir = require('../common/tmpdir'); + +tmpdir.refresh(); +writeFileSync(tmpdir.resolve('first file.env'), + 'NODE_TEST_ENV_FILE=first\nNODE_TEST_ENV_FILE_ONLY=first\nNODE_OPTIONS=--stack-trace-limit=99\n'); +writeFileSync(tmpdir.resolve('second.env'), 'NODE_TEST_ENV_FILE=second\n'); + +const env = { ...process.env }; +delete env.NODE_TEST_ENV_FILE; +delete env.NODE_TEST_ENV_FILE_ONLY; + +for (const flag of ['--env-file', '--env-file-if-exists']) { + for (const separator of ['=', ' ']) { + it('loads ' + flag + separator + 'file from NODE_OPTIONS', () => { + assert(process.allowedNodeEnvironmentFlags.has(flag)); + spawnSyncAndAssert( + process.execPath, + ['-p', 'process.env.NODE_TEST_ENV_FILE'], + { + cwd: tmpdir.path, + env: { ...env, NODE_OPTIONS: flag + separator + '"first file.env"' }, + }, + { stdout: 'first\n' }, + ); + }); + } + + it('loads multiple ' + flag + ' files in order', () => { + spawnSyncAndAssert( + process.execPath, + ['-p', 'process.env.NODE_TEST_ENV_FILE + ":" + process.env.NODE_TEST_ENV_FILE_ONLY'], + { + cwd: tmpdir.path, + env: { ...env, NODE_OPTIONS: flag + '="first file.env" ' + flag + '=second.env' }, + }, + { stdout: 'second:first\n' }, + ); + }); + + it('loads ' + flag + ' from NODE_OPTIONS before command-line files', () => { + spawnSyncAndAssert( + process.execPath, + ['--env-file=second.env', '-p', 'process.env.NODE_TEST_ENV_FILE + ":" + process.env.NODE_TEST_ENV_FILE_ONLY'], + { + cwd: tmpdir.path, + env: { ...env, NODE_OPTIONS: flag + '="first file.env"' }, + }, + { stdout: 'second:first\n' }, + ); + }); + + it('allows access to variables loaded by ' + flag + ' with permissions', () => { + spawnSyncAndAssert( + process.execPath, + ['--permission', '-p', + 'process.env.NODE_TEST_ENV_FILE + ":" + process.permission.has("env", "NODE_TEST_ENV_FILE")'], + { + cwd: tmpdir.path, + env: { ...env, NODE_OPTIONS: flag + '="first file.env"' }, + }, + { stdout: 'first:true\n' }, + ); + }); + + it('preserves inherited environment variables with ' + flag, () => { + spawnSyncAndAssert( + process.execPath, + ['-p', 'process.env.NODE_TEST_ENV_FILE + ":" + Error.stackTraceLimit'], + { + cwd: tmpdir.path, + env: { + ...env, + NODE_OPTIONS: flag + '="first file.env" --stack-trace-limit=42', + NODE_TEST_ENV_FILE: 'inherited', + }, + }, + { stdout: 'inherited:42\n' }, + ); + }); + + it('requires a file argument for ' + flag, () => { + spawnSyncAndAssert( + process.execPath, + ['-e', ''], + { cwd: tmpdir.path, env: { ...env, NODE_OPTIONS: flag } }, + { status: 9, stderr: /requires an argument/ }, + ); + }); + + it('handles a missing file with ' + flag, () => { + const optional = flag === '--env-file-if-exists'; + spawnSyncAndAssert( + process.execPath, + ['-e', 'console.log("ok")'], + { cwd: tmpdir.path, env: { ...env, NODE_OPTIONS: flag + '=missing.env' } }, + { + status: optional ? 0 : 9, + stdout: optional ? 'ok\n' : '', + stderr: optional ? /missing\.env not found/ : /missing\.env: not found/, + }, + ); + }); +} + +it('does not load files recursively from dotenv NODE_OPTIONS', () => { + writeFileSync(tmpdir.resolve('recursive.env'), + 'NODE_TEST_ENV_FILE=outer\nNODE_OPTIONS=--env-file=recursive.env\n'); + spawnSyncAndAssert( + process.execPath, + ['-p', 'process.env.NODE_TEST_ENV_FILE'], + { cwd: tmpdir.path, env: { ...env, NODE_OPTIONS: '--env-file=recursive.env' } }, + { stdout: 'outer\n' }, + ); +}); + +it('reports malformed NODE_OPTIONS before loading files', () => { + spawnSyncAndAssert( + process.execPath, + ['-e', ''], + { cwd: tmpdir.path, env: { ...env, NODE_OPTIONS: '--env-file="first file.env' } }, + { status: 9, stderr: /invalid value for NODE_OPTIONS \(unterminated string\)/ }, + ); +});