Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guides/runtime/read-env.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Bun.env.API_TOKEN; // => "secret"

---

To print all currently-set environment variables, run `bun --print process.env`.
To print all currently-set environment variables, run `bun --print process.env`. Values auto-loaded from `.env` files are non-enumerable and do not appear here; use `bun --print 'Object.getOwnPropertyNames(process.env)'` to list every key.

```sh terminal icon="terminal"
bun --print process.env
Expand Down
8 changes: 7 additions & 1 deletion docs/runtime/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ process.env.BAR; // => "hello$FOO"

Bun reads `.env` files automatically, so `dotenv` and `dotenv-expand` are unnecessary.

### Enumeration

Values that Bun auto-loads from `.env` files are readable via `process.env.NAME` but are **not enumerable**: `Object.keys(process.env)`, `for..in`, and `{ ...process.env }` list only variables from the OS environment and from explicit `--env-file` arguments. This keeps `process.env` enumeration Node-compatible so tools with their own `.env.{mode}` loading (such as Vite's `loadEnv`) do not mistake Bun's auto-loaded values for shell-provided overrides. Assigning to such a key from JavaScript promotes it to an enumerable own property. `Object.getOwnPropertyNames(process.env)` lists all keys including the auto-loaded ones.

Default inherited environments (`Bun.spawn` with no `env`, `child_process` with no `options.env`, `Bun.$`, `worker_threads`, `cluster.fork`, and the WASI runner) still receive auto-loaded values. Once a `worker_threads` `SHARE_ENV` tree is founded, `process.env` becomes a shared string map and auto-loaded values enumerate like any other entry on the threads in that tree.
Comment thread
robobun marked this conversation as resolved.

## Reading environment variables

Read the current environment variables from `process.env`.
Expand All @@ -154,7 +160,7 @@ Bun.env.API_TOKEN; // => "secret"
import.meta.env.API_TOKEN; // => "secret"
```

To print all currently-set environment variables, run `bun --print process.env`.
To print all currently-set environment variables, run `bun --print process.env`. This lists variables from the OS environment and explicit `--env-file` arguments; values auto-loaded from `.env` files are [non-enumerable](#enumeration) and do not appear here. Use `bun --print 'Object.getOwnPropertyNames(process.env)'` to list every key including auto-loaded ones.

```sh
bun --print process.env
Expand Down
60 changes: 49 additions & 11 deletions src/dotenv/env_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,14 +449,18 @@ impl Loader {
*cxx_gop.key_ptr = Box::<[u8]>::from(&**cxx_gop.key_ptr);
*cxx_gop.value_ptr = HashTableValue {
value: ccache_path.clone(),
conditional: false,
};
}
let c_gop = self
.map
.get_or_put_without_value(b"CMAKE_C_COMPILER_LAUNCHER")?;
if !c_gop.found_existing {
*c_gop.key_ptr = Box::<[u8]>::from(&**c_gop.key_ptr);
*c_gop.value_ptr = HashTableValue { value: ccache_path };
*c_gop.value_ptr = HashTableValue {
value: ccache_path,
conditional: false,
};
}
}
Ok(())
Expand Down Expand Up @@ -611,7 +615,11 @@ impl Loader {
// `Source.contents: &'static [u8]` lifetime constraint (callers like
// `node:util.parseEnv` pass JS-owned non-'static buffers).
let mut value_buffer: Vec<u8> = Vec::new();
Parser::parse_bytes::<OVERWRITE, false, EXPAND>(str, &mut self.map, &mut value_buffer)
Parser::parse_bytes::<OVERWRITE, false, EXPAND, false>(
str,
&mut self.map,
&mut value_buffer,
)
}

pub fn load<D: DirEntryProbe + ?Sized>(
Expand Down Expand Up @@ -870,7 +878,11 @@ impl Loader {
}
}
ReadEnvFile::Bytes(buf) => {
Parser::parse_bytes::<OVERRIDE, false, true>(&buf, &mut self.map, value_buffer)?;
Parser::parse_bytes::<OVERRIDE, false, true, true>(
&buf,
&mut self.map,
value_buffer,
)?;
}
}

Expand Down Expand Up @@ -917,7 +929,11 @@ impl Loader {
}
}
ReadEnvFile::Bytes(buf) => {
Parser::parse_bytes::<OVERRIDE, false, true>(&buf, &mut self.map, value_buffer)?;
Parser::parse_bytes::<OVERRIDE, false, true, false>(
&buf,
&mut self.map,
value_buffer,
)?;
}
}

Expand Down Expand Up @@ -1207,7 +1223,12 @@ impl<'a> Parser<'a> {
Ok(Some(self.value_buffer.as_slice()))
}

fn _parse<const OVERRIDE: bool, const IS_PROCESS: bool, const EXPAND: bool>(
fn _parse<
const OVERRIDE: bool,
const IS_PROCESS: bool,
const EXPAND: bool,
const CONDITIONAL: bool,
>(
&mut self,
map: &mut Map,
) -> Result<(), AllocError> {
Expand All @@ -1231,7 +1252,10 @@ impl<'a> Parser<'a> {
}
// else: previous value freed by Drop on assignment below
}
*entry.value_ptr = HashTableValue { value: value_owned };
*entry.value_ptr = HashTableValue {
value: value_owned,
conditional: CONDITIONAL,
};
}
if !IS_PROCESS && EXPAND {
// borrowck — index-based iteration: clone the value bytes, run
Expand All @@ -1243,9 +1267,7 @@ impl<'a> Parser<'a> {
while idx < total {
let current: Box<[u8]> = Box::from(&*map.map.values()[idx].value);
if let Some(expanded) = self.expand_value(map, &current)? {
map.map.values_mut()[idx] = HashTableValue {
value: Box::from(expanded),
};
map.map.values_mut()[idx].value = Box::from(expanded);
}
idx += 1;
}
Expand All @@ -1258,7 +1280,12 @@ impl<'a> Parser<'a> {
/// Same as [`parse`] but takes the source bytes directly. Exists so
/// `load_env_file*` can parse a transient `Vec<u8>` without constructing a
/// `bun_ast::Source` (whose `contents` field is currently `&'static [u8]`).
pub(crate) fn parse_bytes<const OVERRIDE: bool, const IS_PROCESS: bool, const EXPAND: bool>(
pub(crate) fn parse_bytes<
const OVERRIDE: bool,
const IS_PROCESS: bool,
const EXPAND: bool,
const CONDITIONAL: bool,
>(
src: &[u8],
map: &mut Map,
value_buffer: &mut Vec<u8>,
Expand All @@ -1272,7 +1299,7 @@ impl<'a> Parser<'a> {
src: strings::without_utf8_bom(src),
value_buffer,
};
parser._parse::<OVERRIDE, IS_PROCESS, EXPAND>(map)
parser._parse::<OVERRIDE, IS_PROCESS, EXPAND, CONDITIONAL>(map)
}
}

Expand All @@ -1281,6 +1308,13 @@ pub struct HashTableValue {
// `Box<[u8]>` is owned-by-default, trading some copies for uniform
// ownership.
pub value: Box<[u8]>,
/// Set for keys that exist only because Bun auto-discovered a `.env*` file,
/// not because the OS environment, `--env-file`, or an explicit `put()`
/// supplied them. `createEnvironmentVariablesMap` adds these with
/// `DontEnum` so tooling that re-reads `.env.{mode}` itself (Vite's
/// `loadEnv`, dotenv-flow, etc.) does not mistake them for process-level
/// overrides when enumerating `process.env`.
Comment thread
robobun marked this conversation as resolved.
pub conditional: bool,
}

// On Windows, environment variables are case-insensitive. So we use a case-insensitive hash map.
Expand Down Expand Up @@ -1411,6 +1445,7 @@ impl Map {
key,
HashTableValue {
value: Box::from(value),
conditional: false,
},
)
}
Expand All @@ -1428,6 +1463,7 @@ impl Map {
key,
HashTableValue {
value: Box::from(value),
conditional: false,
},
);
}
Expand All @@ -1437,6 +1473,7 @@ impl Map {
let gop = self.map.get_or_put(key)?;
*gop.value_ptr = HashTableValue {
value: Box::from(value),
conditional: false,
};
if !gop.found_existing {
*gop.key_ptr = Box::from(key);
Expand Down Expand Up @@ -1471,6 +1508,7 @@ impl Map {
key,
HashTableValue {
value: Box::from(value),
conditional: false,
},
)?;
Ok(())
Expand Down
1 change: 1 addition & 0 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@ fn configure_env_for_scripts_run(
value: Box::<[u8]>::from(strings::without_trailing_slash(
FileSystem::instance().top_level_dir(),
)),
conditional: false,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/install_jsc/ini_jsc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ impl IniTestingAPIs {
&keyslice,
dotenv::map::Entry {
value: slice.into_boxed_slice(),
conditional: false,
},
)?;
}
Expand Down
32 changes: 17 additions & 15 deletions src/js/builtins/ProcessObjectInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,24 +453,21 @@ export function windowsEnv(
//
// it throws "Cannot convert a Symbol value to a string"

(internalEnv as any)[Bun.inspect.custom] = () => {
const enumerableView = () => {
let o = {};
for (let k of envMapList) {
o[k] = internalEnv[k.toUpperCase()];
}
return o;
};

(internalEnv as any).toJSON = () => {
// Mirror enumeration: original-case key names, case-insensitive values.
// Spreading internalEnv directly would leak the canonical UPPERCASE
// storage keys into JSON.stringify(process.env) and IPC env echoes.
let o = {};
for (let k of envMapList) {
o[k] = internalEnv[k.toUpperCase()];
const up = k.toUpperCase();
if ($Object.getOwnPropertyDescriptor(internalEnv, up)?.enumerable) {
o[k] = internalEnv[up];
}
}
return o;
};
(internalEnv as any)[Bun.inspect.custom] = enumerableView;
// Mirror enumeration: original-case key names, case-insensitive values.
// Spreading internalEnv directly would leak the canonical UPPERCASE
// storage keys into JSON.stringify(process.env) and IPC env echoes.
Comment thread
robobun marked this conversation as resolved.
(internalEnv as any).toJSON = enumerableView;

return new Proxy(internalEnv, {
get(_, p) {
Expand Down Expand Up @@ -505,8 +502,10 @@ export function windowsEnv(
}
if (internalEnv[k] !== value) {
editWindowsEnvVar(k, value);
internalEnv[k] = value;
}
// Unconditional so a same-value write to a DontEnum auto-loaded .env key
// still promotes it to an enumerable data property via the custom setter.
Comment thread
robobun marked this conversation as resolved.
internalEnv[k] = value;
return true;
},
has(_, p) {
Expand All @@ -530,7 +529,10 @@ export function windowsEnv(
defineProperty(_, p, attributes) {
const k = String(p).toUpperCase();
$assert(typeof p === "string"); // proxy is only string and symbol. the symbol would have thrown by now
if (!(k in internalEnv) && !envMapList.includes(p)) {
// Gate on envMapList membership, not `k in internalEnv`: the
// always-present TZ/proxy accessors are own properties of internalEnv
// while correctly absent from envMapList.
Comment thread
robobun marked this conversation as resolved.
if (!envMapList.includes(p) && !envMapList.some(x => x.toUpperCase() === k)) {
envMapList.push(p);
}
editWindowsEnvVar(k, internalEnv[k]);
Expand Down
15 changes: 12 additions & 3 deletions src/js/builtins/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
newEnv = defaultEnv;
}

this.#args!.setEnv(newEnv);
this.#args!.setEnv(newEnv === originalDefaultEnv ? snapshotProcessEnv(newEnv) : newEnv);
return this;
}

Expand Down Expand Up @@ -253,6 +253,15 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa
const originalDefaultEnv = defaultEnv;
var defaultCwd: string | undefined = undefined;

function snapshotProcessEnv(env) {
const out = {};
for (const key of $Object.getOwnPropertyNames(env)) {
const v = env[key];
if (v !== undefined && typeof v !== "function") out[key] = v;
}
return out;
}
Comment thread
robobun marked this conversation as resolved.

const cwdSymbol = Symbol("cwd");
const envSymbol = Symbol("env");
const throwsSymbol = Symbol("throws");
Expand Down Expand Up @@ -309,7 +318,7 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa

// cwd must be set before env or else it will be injected into env as "PWD=/"
if (cwd) parsed_shell_script.setCwd(cwd);
if (env) parsed_shell_script.setEnv(env);
if (env) parsed_shell_script.setEnv(env === originalDefaultEnv ? snapshotProcessEnv(env) : env);

return new ShellPromise(parsed_shell_script, throws);
};
Expand All @@ -329,7 +338,7 @@ export function createBunShellTemplateFunction(createShellInterpreter_, createPa

// cwd must be set before env or else it will be injected into env as "PWD=/"
if (cwd) parsed_shell_script.setCwd(cwd);
if (env) parsed_shell_script.setEnv(env);
if (env) parsed_shell_script.setEnv(env === originalDefaultEnv ? snapshotProcessEnv(env) : env);

return new ShellPromise(parsed_shell_script, throws);
};
Expand Down
7 changes: 6 additions & 1 deletion src/js/internal/cluster/primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ function setupSettingsNT(settings) {
}

function createWorkerProcess(id, env) {
const workerEnv = { ...process.env, ...env, NODE_UNIQUE_ID: `${id}` };
const workerEnv = {};
for (const k of $Object.getOwnPropertyNames(process.env)) {
const v = process.env[k];
if (v !== undefined && typeof v !== "function") workerEnv[k] = v;
}
Object.assign(workerEnv, env, { NODE_UNIQUE_ID: `${id}` });
const execArgv = [...cluster.settings.execArgv];

// if (cluster.settings.inspectPort === null) {
Expand Down
10 changes: 8 additions & 2 deletions src/js/node/child_process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,8 +1020,14 @@ function normalizeSpawnArguments(file, args, options) {
// copyProcessEnvToEnv(env, "NODE_V8_COVERAGE", options.env);

let envKeys: string[] = [];
for (const key in env) {
ArrayPrototypePush.$call(envKeys, key);
if (env === process.env) {
for (const key of $Object.getOwnPropertyNames(env)) {
ArrayPrototypePush.$call(envKeys, key);
}
} else {
for (const key in env) {
ArrayPrototypePush.$call(envKeys, key);
}
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}

if (process.platform === "win32") {
Expand Down
15 changes: 14 additions & 1 deletion src/js/node/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,10 +473,23 @@ async function runOneFile(
reporter.enqueue({ __proto__: null, ...fileNode });
reporter.dequeue({ __proto__: null, ...fileNode });

const baseEnv: Record<string, string> = {};
const optsEnv = opts.env;
if (optsEnv) {
Object.assign(baseEnv, optsEnv);
} else {
for (const k of $Object.getOwnPropertyNames(process.env)) {
const v = process.env[k];
if (v !== undefined && typeof v !== "function") baseEnv[k] = v;
}
}
baseEnv.BUN_TEST_DRAIN_EVENT_LOOP = "1";
baseEnv[kRunChildEnv] = kRunChildEnvValue;

const proc = Bun.spawn({
cmd: args,
cwd: opts.cwd as string,
env: { ...(opts.env ?? process.env), BUN_TEST_DRAIN_EVENT_LOOP: "1", [kRunChildEnv]: kRunChildEnvValue },
env: baseEnv,
stdout: "pipe",
stderr: "pipe",
signal: opts.signal,
Expand Down
6 changes: 5 additions & 1 deletion src/js/wasi-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ var {
WASM_USE_ASYNC_INIT = "1",
} = process.env;

var env = process.env;
var env = {};
for (const k of Object.getOwnPropertyNames(process.env)) {
const v = process.env[k];
if (v !== undefined && typeof v !== "function") env[k] = v;
}
if (WASM_ENV_STR?.length) {
env = JSON.parse(WASM_ENV_STR);
}
Expand Down
Loading
Loading