Skip to content

Commit c1edd49

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

File tree

8 files changed

+190
-20
lines changed

8 files changed

+190
-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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,8 @@ 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,
877+
file_data.sections)) {
877878
case Dotenv::ParseResult::Valid:
878879
break;
879880
case Dotenv::ParseResult::InvalidContent:

src/node_dotenv.cc

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,33 @@ 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
46+
// index
47+
auto section = path.substr(hash_char_index + 1,
48+
next_hash_char_index - 1 - hash_char_index);
49+
sections.insert(section);
50+
start_index = next_hash_char_index;
51+
}
52+
53+
return sections;
54+
};
55+
2956
std::vector<Dotenv::env_file_data> env_files;
3057
// This will be an iterator, pointing to args.end() if no matches are found
3158
auto matched_arg = std::find_if(args.begin(), args.end(), find_match);
@@ -42,19 +69,37 @@ std::vector<Dotenv::env_file_data> Dotenv::GetDataFromArgs(
4269
auto flag = matched_arg->substr(0, equal_char_index);
4370
auto file_path = matched_arg->substr(equal_char_index + 1);
4471

72+
auto sections = get_sections(file_path);
73+
74+
auto hash_char_index = file_path.find('#');
75+
if (hash_char_index != std::string::npos) {
76+
file_path = file_path.substr(0, hash_char_index);
77+
}
78+
4579
struct env_file_data env_file_data = {
46-
file_path, flag.starts_with(optional_env_file_flag)};
80+
file_path, flag.starts_with(optional_env_file_flag), sections};
4781
env_files.push_back(env_file_data);
4882
} else {
4983
// `--env-file path`
50-
auto file_path = std::next(matched_arg);
84+
auto file_path_ptr = std::next(matched_arg);
5185

52-
if (file_path == args.end()) {
86+
if (file_path_ptr == args.end()) {
5387
return env_files;
5488
}
5589

90+
std::string file_path = file_path_ptr->c_str();
91+
92+
auto sections = get_sections(file_path);
93+
94+
auto hash_char_index = file_path.find('#');
95+
if (hash_char_index != std::string::npos) {
96+
file_path = file_path.substr(0, hash_char_index);
97+
}
98+
5699
struct env_file_data env_file_data = {
57-
*file_path, matched_arg->starts_with(optional_env_file_flag)};
100+
file_path,
101+
matched_arg->starts_with(optional_env_file_flag),
102+
sections};
58103
env_files.push_back(env_file_data);
59104
}
60105

@@ -124,9 +169,24 @@ std::string_view trim_spaces(std::string_view input) {
124169
return input.substr(pos_start, pos_end - pos_start + 1);
125170
}
126171

127-
void Dotenv::ParseContent(const std::string_view input) {
172+
void Dotenv::ParseContent(const std::string_view input,
173+
const std::set<std::string> sections) {
128174
std::string lines(input);
129175

176+
// Variable to track the current section ("" indicates that we're in the
177+
// global/top-level section)
178+
std::string current_section = "";
179+
180+
// Insert/Assign a value in the store, but only if it's in the global section
181+
// or in an included section
182+
auto maybe_insert_or_assign_to_store = [&](const std::string& key,
183+
const std::string_view& value) {
184+
if (current_section.empty() ||
185+
(sections.find(current_section.c_str()) != sections.end())) {
186+
store_.insert_or_assign(key, value);
187+
}
188+
};
189+
130190
// Handle windows newlines "\r\n": remove "\r" and keep only "\n"
131191
lines.erase(std::remove(lines.begin(), lines.end(), '\r'), lines.end());
132192

@@ -154,6 +214,18 @@ void Dotenv::ParseContent(const std::string_view input) {
154214
continue;
155215
}
156216

217+
if (content.front() == '[') {
218+
auto closing_bracket_idx = content.find_first_of(']');
219+
if (closing_bracket_idx != std::string_view::npos) {
220+
if (content.at(closing_bracket_idx + 1) == '\n') {
221+
// We've enterer a new section of the file
222+
current_section = content.substr(1, closing_bracket_idx - 1);
223+
content.remove_prefix(closing_bracket_idx + 1);
224+
continue;
225+
}
226+
}
227+
}
228+
157229
// Find the next equals sign or newline in a single pass.
158230
// This optimizes the search by avoiding multiple iterations.
159231
auto equal_or_newline = content.find_first_of("=\n");
@@ -176,7 +248,7 @@ void Dotenv::ParseContent(const std::string_view input) {
176248

177249
// If the value is not present (e.g. KEY=) set it to an empty string
178250
if (content.empty() || content.front() == '\n') {
179-
store_.insert_or_assign(std::string(key), "");
251+
maybe_insert_or_assign_to_store(std::string(key), "");
180252
continue;
181253
}
182254

@@ -201,7 +273,7 @@ void Dotenv::ParseContent(const std::string_view input) {
201273
if (content.empty()) {
202274
// In case the last line is a single key without value
203275
// Example: KEY= (without a newline at the EOF)
204-
store_.insert_or_assign(std::string(key), "");
276+
maybe_insert_or_assign_to_store(std::string(key), "");
205277
break;
206278
}
207279

@@ -221,7 +293,7 @@ void Dotenv::ParseContent(const std::string_view input) {
221293
pos += 1;
222294
}
223295

224-
store_.insert_or_assign(std::string(key), multi_line_value);
296+
maybe_insert_or_assign_to_store(std::string(key), multi_line_value);
225297
auto newline = content.find('\n', closing_quote + 1);
226298
if (newline != std::string_view::npos) {
227299
content.remove_prefix(newline + 1);
@@ -248,18 +320,18 @@ void Dotenv::ParseContent(const std::string_view input) {
248320
auto newline = content.find('\n');
249321
if (newline != std::string_view::npos) {
250322
value = content.substr(0, newline);
251-
store_.insert_or_assign(std::string(key), value);
323+
maybe_insert_or_assign_to_store(std::string(key), value);
252324
content.remove_prefix(newline + 1);
253325
} else {
254326
// No newline - take rest of content
255327
value = content;
256-
store_.insert_or_assign(std::string(key), value);
328+
maybe_insert_or_assign_to_store(std::string(key), value);
257329
break;
258330
}
259331
} else {
260332
// Found closing quote - take content between quotes
261333
value = content.substr(1, closing_quote - 1);
262-
store_.insert_or_assign(std::string(key), value);
334+
maybe_insert_or_assign_to_store(std::string(key), value);
263335
auto newline = content.find('\n', closing_quote + 1);
264336
if (newline != std::string_view::npos) {
265337
// Use +1 to discard the '\n' itself => next line
@@ -285,7 +357,7 @@ void Dotenv::ParseContent(const std::string_view input) {
285357
value = value.substr(0, hash_character);
286358
}
287359
value = trim_spaces(value);
288-
store_.insert_or_assign(std::string(key), std::string(value));
360+
maybe_insert_or_assign_to_store(std::string(key), std::string(value));
289361
content.remove_prefix(newline + 1);
290362
} else {
291363
// Last line without newline
@@ -294,7 +366,7 @@ void Dotenv::ParseContent(const std::string_view input) {
294366
if (hash_char != std::string_view::npos) {
295367
value = content.substr(0, hash_char);
296368
}
297-
store_.insert_or_assign(std::string(key), trim_spaces(value));
369+
maybe_insert_or_assign_to_store(std::string(key), trim_spaces(value));
298370
content = {};
299371
}
300372
}
@@ -303,7 +375,8 @@ void Dotenv::ParseContent(const std::string_view input) {
303375
}
304376
}
305377

306-
Dotenv::ParseResult Dotenv::ParsePath(const std::string_view path) {
378+
Dotenv::ParseResult Dotenv::ParsePath(const std::string_view path,
379+
const std::set<std::string> sections) {
307380
uv_fs_t req;
308381
auto defer_req_cleanup = OnScopeLeave([&req]() { uv_fs_req_cleanup(&req); });
309382

@@ -337,7 +410,7 @@ Dotenv::ParseResult Dotenv::ParsePath(const std::string_view path) {
337410
result.append(buf.base, r);
338411
}
339412

340-
ParseContent(result);
413+
ParseContent(result, sections);
341414
return ParseResult::Valid;
342415
}
343416

src/node_dotenv.h

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

99
#include <map>
10+
#include <set>
1011

1112
namespace node {
1213

@@ -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,10 @@ 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,
31+
const std::set<std::string> sections);
32+
ParseResult ParsePath(const std::string_view path,
33+
const std::set<std::string>);
3034
void AssignNodeOptionsIfAvailable(std::string* node_options) const;
3135
v8::Maybe<void> SetEnvironment(Environment* env);
3236
v8::MaybeLocal<v8::Object> ToObject(Environment* env) const;

src/node_process_methods.cc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,9 @@ 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
608+
// sections
609+
switch (dotenv.ParsePath(path, {})) {
608610
case dotenv.ParseResult::Valid: {
609611
USE(dotenv.SetEnvironment(env));
610612
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)