Skip to content

Commit ff88ca1

Browse files
src: extend --env-file to also accept sections
1 parent e679e38 commit ff88ca1

File tree

8 files changed

+176
-20
lines changed

8 files changed

+176
-20
lines changed

doc/api/cli.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,29 @@ Export keyword before a key is ignored:
905905
export USERNAME="nodejs" # will result in `nodejs` as the value.
906906
```
907907

908+
Additionally sections can be used to have a more granular control of
909+
the environment variables within a single file.
910+
911+
Sections can be defined in the file and then targeted by including a hash character
912+
followed by their name as the flag's argument. Multiple sections can be specified and
913+
if a variable is defined in multiple sections the latest instance of the variable in
914+
the file overrides the others.
915+
916+
For example given the following file:
917+
918+
```text
919+
MY_VAR = 'my top-level variable'
920+
921+
[dev]
922+
MY_VAR = 'my variable for development'
923+
924+
[prod]
925+
MY_VAR = 'my variable for production'
926+
```
927+
928+
`--env-file=config#dev` will make it so that the variable's value being used is
929+
taken from the `dev` section.
930+
908931
If you want to load environment variables from a file that may not exist, you
909932
can use the [`--env-file-if-exists`][] flag instead.
910933

src/node.cc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,7 @@ static ExitCode InitializeNodeWithArgsInternal(
873873
CHECK(!per_process::v8_initialized);
874874

875875
for (const auto& file_data : env_files) {
876-
switch (per_process::dotenv_file.ParsePath(file_data.path)) {
876+
switch (per_process::dotenv_file.ParsePath(file_data.path, file_data.sections)) {
877877
case Dotenv::ParseResult::Valid:
878878
break;
879879
case Dotenv::ParseResult::InvalidContent:

src/node_dotenv.cc

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,31 @@ std::vector<Dotenv::env_file_data> Dotenv::GetDataFromArgs(
2626
arg.starts_with("--env-file-if-exists=");
2727
};
2828

29+
const auto get_sections = [](const std::string& path) {
30+
std::set<std::string> sections = {};
31+
std::int8_t start_index = 0;
32+
33+
while (true) {
34+
auto hash_char_index = path.find('#', start_index);
35+
if (hash_char_index == std::string::npos) {
36+
return sections;
37+
}
38+
auto next_hash_char_index = path.find('#', hash_char_index + 1);
39+
if (next_hash_char_index == std::string::npos) {
40+
// We've arrived to the last section
41+
auto section = path.substr(hash_char_index + 1);
42+
sections.insert(section);
43+
return sections;
44+
}
45+
// There are more sections, so let's save the current one and update the index
46+
auto section = path.substr(hash_char_index+1, next_hash_char_index - 1 - hash_char_index);
47+
sections.insert(section);
48+
start_index = next_hash_char_index;
49+
}
50+
51+
return sections;
52+
};
53+
2954
std::vector<Dotenv::env_file_data> env_files;
3055
// This will be an iterator, pointing to args.end() if no matches are found
3156
auto matched_arg = std::find_if(args.begin(), args.end(), find_match);
@@ -42,19 +67,35 @@ std::vector<Dotenv::env_file_data> Dotenv::GetDataFromArgs(
4267
auto flag = matched_arg->substr(0, equal_char_index);
4368
auto file_path = matched_arg->substr(equal_char_index + 1);
4469

70+
auto sections = get_sections(file_path);
71+
72+
auto hash_char_index = file_path.find('#');
73+
if (hash_char_index != std::string::npos) {
74+
file_path = file_path.substr(0, hash_char_index);
75+
}
76+
4577
struct env_file_data env_file_data = {
46-
file_path, flag.starts_with(optional_env_file_flag)};
78+
file_path, flag.starts_with(optional_env_file_flag), sections};
4779
env_files.push_back(env_file_data);
4880
} else {
4981
// `--env-file path`
50-
auto file_path = std::next(matched_arg);
82+
auto file_path_ptr = std::next(matched_arg);
5183

52-
if (file_path == args.end()) {
84+
if (file_path_ptr == args.end()) {
5385
return env_files;
5486
}
5587

88+
std::string file_path = file_path_ptr->c_str();
89+
90+
auto sections = get_sections(file_path);
91+
92+
auto hash_char_index = file_path.find('#');
93+
if (hash_char_index != std::string::npos) {
94+
file_path = file_path.substr(0, hash_char_index);
95+
}
96+
5697
struct env_file_data env_file_data = {
57-
*file_path, matched_arg->starts_with(optional_env_file_flag)};
98+
file_path, matched_arg->starts_with(optional_env_file_flag), sections};
5899
env_files.push_back(env_file_data);
59100
}
60101

@@ -124,9 +165,19 @@ std::string_view trim_spaces(std::string_view input) {
124165
return input.substr(pos_start, pos_end - pos_start + 1);
125166
}
126167

127-
void Dotenv::ParseContent(const std::string_view input) {
168+
void Dotenv::ParseContent(const std::string_view input, const std::set<std::string> sections) {
128169
std::string lines(input);
129170

171+
// Variable to track the current section ("" indicates that we're in the global/top-level section)
172+
std::string current_section = "";
173+
174+
// Insert/Assign a value in the store, but only if it's in the global section or in an included section
175+
auto maybe_insert_or_assign_to_store = [&](const std::string& key, const std::string_view& value) {
176+
if (current_section.empty() || (sections.find(current_section.c_str()) != sections.end())) {
177+
store_.insert_or_assign(key, value);
178+
}
179+
};
180+
130181
// Handle windows newlines "\r\n": remove "\r" and keep only "\n"
131182
lines.erase(std::remove(lines.begin(), lines.end(), '\r'), lines.end());
132183

@@ -154,6 +205,18 @@ void Dotenv::ParseContent(const std::string_view input) {
154205
continue;
155206
}
156207

208+
if (content.front() == '[') {
209+
auto closing_bracket_idx = content.find_first_of(']');
210+
if (closing_bracket_idx != std::string_view::npos) {
211+
if(content.at(closing_bracket_idx + 1) == '\n') {
212+
// We've enterer a new section of the file
213+
current_section = content.substr(1, closing_bracket_idx - 1);
214+
content.remove_prefix(closing_bracket_idx + 1);
215+
continue;
216+
}
217+
}
218+
}
219+
157220
// Find the next equals sign or newline in a single pass.
158221
// This optimizes the search by avoiding multiple iterations.
159222
auto equal_or_newline = content.find_first_of("=\n");
@@ -176,7 +239,7 @@ void Dotenv::ParseContent(const std::string_view input) {
176239

177240
// If the value is not present (e.g. KEY=) set it to an empty string
178241
if (content.empty() || content.front() == '\n') {
179-
store_.insert_or_assign(std::string(key), "");
242+
maybe_insert_or_assign_to_store(std::string(key), "");
180243
continue;
181244
}
182245

@@ -201,7 +264,7 @@ void Dotenv::ParseContent(const std::string_view input) {
201264
if (content.empty()) {
202265
// In case the last line is a single key without value
203266
// Example: KEY= (without a newline at the EOF)
204-
store_.insert_or_assign(std::string(key), "");
267+
maybe_insert_or_assign_to_store(std::string(key), "");
205268
break;
206269
}
207270

@@ -221,7 +284,7 @@ void Dotenv::ParseContent(const std::string_view input) {
221284
pos += 1;
222285
}
223286

224-
store_.insert_or_assign(std::string(key), multi_line_value);
287+
maybe_insert_or_assign_to_store(std::string(key), multi_line_value);
225288
auto newline = content.find('\n', closing_quote + 1);
226289
if (newline != std::string_view::npos) {
227290
content.remove_prefix(newline + 1);
@@ -248,18 +311,18 @@ void Dotenv::ParseContent(const std::string_view input) {
248311
auto newline = content.find('\n');
249312
if (newline != std::string_view::npos) {
250313
value = content.substr(0, newline);
251-
store_.insert_or_assign(std::string(key), value);
314+
maybe_insert_or_assign_to_store(std::string(key), value);
252315
content.remove_prefix(newline + 1);
253316
} else {
254317
// No newline - take rest of content
255318
value = content;
256-
store_.insert_or_assign(std::string(key), value);
319+
maybe_insert_or_assign_to_store(std::string(key), value);
257320
break;
258321
}
259322
} else {
260323
// Found closing quote - take content between quotes
261324
value = content.substr(1, closing_quote - 1);
262-
store_.insert_or_assign(std::string(key), value);
325+
maybe_insert_or_assign_to_store(std::string(key), value);
263326
auto newline = content.find('\n', closing_quote + 1);
264327
if (newline != std::string_view::npos) {
265328
// Use +1 to discard the '\n' itself => next line
@@ -285,7 +348,7 @@ void Dotenv::ParseContent(const std::string_view input) {
285348
value = value.substr(0, hash_character);
286349
}
287350
value = trim_spaces(value);
288-
store_.insert_or_assign(std::string(key), std::string(value));
351+
maybe_insert_or_assign_to_store(std::string(key), std::string(value));
289352
content.remove_prefix(newline + 1);
290353
} else {
291354
// Last line without newline
@@ -294,7 +357,7 @@ void Dotenv::ParseContent(const std::string_view input) {
294357
if (hash_char != std::string_view::npos) {
295358
value = content.substr(0, hash_char);
296359
}
297-
store_.insert_or_assign(std::string(key), trim_spaces(value));
360+
maybe_insert_or_assign_to_store(std::string(key), trim_spaces(value));
298361
content = {};
299362
}
300363
}
@@ -303,7 +366,7 @@ void Dotenv::ParseContent(const std::string_view input) {
303366
}
304367
}
305368

306-
Dotenv::ParseResult Dotenv::ParsePath(const std::string_view path) {
369+
Dotenv::ParseResult Dotenv::ParsePath(const std::string_view path, const std::set<std::string> sections) {
307370
uv_fs_t req;
308371
auto defer_req_cleanup = OnScopeLeave([&req]() { uv_fs_req_cleanup(&req); });
309372

@@ -337,7 +400,7 @@ Dotenv::ParseResult Dotenv::ParsePath(const std::string_view path) {
337400
result.append(buf.base, r);
338401
}
339402

340-
ParseContent(result);
403+
ParseContent(result, sections);
341404
return ParseResult::Valid;
342405
}
343406

src/node_dotenv.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include "util-inl.h"
77
#include "v8.h"
88

9+
#include <set>
910
#include <map>
1011

1112
namespace node {
@@ -16,6 +17,7 @@ class Dotenv {
1617
struct env_file_data {
1718
std::string path;
1819
bool is_optional;
20+
std::set<std::string> sections;
1921
};
2022

2123
Dotenv() = default;
@@ -25,8 +27,8 @@ class Dotenv {
2527
Dotenv& operator=(const Dotenv& d) = delete;
2628
~Dotenv() = default;
2729

28-
void ParseContent(const std::string_view content);
29-
ParseResult ParsePath(const std::string_view path);
30+
void ParseContent(const std::string_view content, const std::set<std::string> sections);
31+
ParseResult ParsePath(const std::string_view path, const std::set<std::string>);
3032
void AssignNodeOptionsIfAvailable(std::string* node_options) const;
3133
v8::Maybe<void> SetEnvironment(Environment* env);
3234
v8::MaybeLocal<v8::Object> ToObject(Environment* env) const;

src/node_process_methods.cc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,8 @@ static void LoadEnvFile(const v8::FunctionCallbackInfo<v8::Value>& args) {
604604

605605
Dotenv dotenv{};
606606

607-
switch (dotenv.ParsePath(path)) {
607+
// TODO(dario-piotrowicz): update `process.loadEnvFile` to also accept sections
608+
switch (dotenv.ParsePath(path, {})) {
608609
case dotenv.ParseResult::Valid: {
609610
USE(dotenv.SetEnvironment(env));
610611
break;

src/node_util.cc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,8 @@ static void ParseEnv(const FunctionCallbackInfo<Value>& args) {
243243
CHECK(args[0]->IsString());
244244
Utf8Value content(env->isolate(), args[0]);
245245
Dotenv dotenv{};
246-
dotenv.ParseContent(content.ToStringView());
246+
// TODO(dario-piotrowicz): update `parseEnv` to also accept sections
247+
dotenv.ParseContent(content.ToStringView(), {});
247248
Local<Object> obj;
248249
if (dotenv.ToObject(env).ToLocal(&obj)) {
249250
args.GetReturnValue().Set(obj);

test/fixtures/dotenv/sections.env

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
_ENV_TEST_A = 'A (top-level)'
2+
_ENV_TEST_B = 'B (top-level)'
3+
4+
[dev]
5+
_ENV_TEST_A = 'A (development)'
6+
_ENV_TEST_C = 'C (development)'
7+
8+
[prod]
9+
_ENV_TEST_A = 'A (production)'
10+
_ENV_TEST_D = 'D (production)'

test/parallel/test-dotenv-sections.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('node:assert');
5+
const { describe, it } = require('node:test');
6+
7+
const envFilePath = '../fixtures/dotenv/sections.env';
8+
9+
async function getProcessEnvTestEntries(envFileArg) {
10+
const code = `
11+
console.log(
12+
JSON.stringify(
13+
Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('_ENV_TEST_')))
14+
)
15+
);
16+
`.trim();
17+
const child = await common.spawnPromisified(
18+
process.execPath,
19+
[ `--env-file=${envFileArg}`, '--eval', code ],
20+
{ cwd: __dirname },
21+
);
22+
assert.strictEqual(child.code, 0);
23+
return JSON.parse(child.stdout.replace(/\n/g, ''));
24+
}
25+
26+
describe('.env sections support', () => {
27+
it('should only get the top-level variables if a section is not specified', async () => {
28+
const env = await getProcessEnvTestEntries(envFilePath);
29+
assert.deepStrictEqual(env, {
30+
_ENV_TEST_A: 'A (top-level)',
31+
_ENV_TEST_B: 'B (top-level)',
32+
});
33+
});
34+
35+
it('should get section specific variables if a section is specified', async () => {
36+
const env = await getProcessEnvTestEntries(`${envFilePath}#dev`);
37+
assert.strictEqual(env._ENV_TEST_A, 'A (development)');
38+
assert.strictEqual(env._ENV_TEST_C, 'C (development)');
39+
assert(!('_ENV_TEST_D' in env), 'the _ENV_TEST_D should not be present for the dev section');
40+
});
41+
42+
it('should allow top-level variables to be inherited if not specified in a section', async () => {
43+
const env = await getProcessEnvTestEntries(`${envFilePath}#dev`);
44+
assert.strictEqual(env._ENV_TEST_B, 'B (top-level)');
45+
});
46+
47+
it('should allow multiple sections to be specified (values are overridden as per the file order)', async () => {
48+
const env = await getProcessEnvTestEntries(`${envFilePath}#dev#prod`);
49+
assert.deepStrictEqual(env, {
50+
_ENV_TEST_A: 'A (production)',
51+
_ENV_TEST_B: 'B (top-level)',
52+
_ENV_TEST_C: 'C (development)',
53+
_ENV_TEST_D: 'D (production)'
54+
});
55+
});
56+
});

0 commit comments

Comments
 (0)