From 509a97a43516fe4f6d4ff4003d1c9aeef89e92ce Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 17 Nov 2025 12:04:42 -0800 Subject: [PATCH] Add --no-env-file flag to disable automatic .env loading (#24767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements `--no-env-file` CLI flag and bunfig configuration options to disable automatic `.env` file loading at runtime and in the bundler. ## Motivation Users may want to disable automatic `.env` file loading for: - Production environments where env vars are managed externally - CI/CD pipelines where .env files should be ignored - Testing scenarios where explicit env control is needed - Security contexts where .env files should not be trusted ## Changes ### CLI Flag - Added `--no-env-file` flag that disables loading of default .env files - Still respects explicit `--env-file` arguments for intentional env loading ### Bunfig Configuration Added support for disabling .env loading via `bunfig.toml`: - `env = false` - disables default .env file loading - `env = null` - disables default .env file loading - `env.file = false` - disables default .env file loading - `env.file = null` - disables default .env file loading ### Implementation - Added `disable_default_env_files` field to `api.TransformOptions` with serialization support - Added `disable_default_env_files` field to `options.Env` struct - Implemented `loadEnvConfig` in bunfig parser to handle env configuration - Wired up flag throughout runtime and bundler code paths - Preserved package.json script runner behavior (always skips default .env files) ## Tests Added comprehensive test suite (`test/cli/run/no-envfile.test.ts`) with 9 tests covering: - `--no-env-file` flag with `.env`, `.env.local`, `.env.development.local` - Bunfig configurations: `env = false`, `env.file = false`, `env = true` - `--no-env-file` with `-e` eval flag - `--no-env-file` combined with `--env-file` (explicit files still load) - Production mode behavior All tests pass with debug bun and fail with system bun (as expected). ## Example Usage ```bash # Disable all default .env files bun --no-env-file index.js # Disable defaults but load explicit file bun --no-env-file --env-file .env.production index.js # Disable via bunfig.toml cat > bunfig.toml << 'CONFIG' env = false CONFIG bun index.js ``` ## Files Changed - `src/cli/Arguments.zig` - CLI flag parsing - `src/api/schema.zig` - API schema field with encode/decode - `src/options.zig` - Env struct field and wiring - `src/bunfig.zig` - Config parsing with loadEnvConfig - `src/transpiler.zig` - Runtime wiring - `src/bun.js.zig` - Runtime wiring - `src/cli/exec_command.zig` - Runtime wiring - `src/cli/run_command.zig` - Preserved package.json script runner behavior - `test/cli/run/no-envfile.test.ts` - Comprehensive test suite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude Bot Co-authored-by: Claude --- src/api/schema.zig | 11 ++ src/bun.js.zig | 2 +- src/bunfig.zig | 41 +++++ src/cli/Arguments.zig | 5 + src/cli/exec_command.zig | 2 +- src/cli/run_command.zig | 2 + src/options.zig | 5 + src/transpiler.zig | 2 +- test/cli/run/no-envfile.test.ts | 265 ++++++++++++++++++++++++++++++++ 9 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 test/cli/run/no-envfile.test.ts diff --git a/src/api/schema.zig b/src/api/schema.zig index 7fafbf53b3..80cfe546de 100644 --- a/src/api/schema.zig +++ b/src/api/schema.zig @@ -1688,6 +1688,9 @@ pub const api = struct { /// env_files env_files: []const []const u8, + /// disable_default_env_files + disable_default_env_files: bool = false, + /// extension_order extension_order: []const []const u8, @@ -1824,6 +1827,9 @@ pub const api = struct { 27 => { this.packages = try reader.readValue(PackagesMode); }, + 28 => { + this.disable_default_env_files = try reader.readValue(bool); + }, else => { return error.InvalidMessage; }, @@ -1944,6 +1950,11 @@ pub const api = struct { try writer.writeValue([]const u8, packages); } + if (this.disable_default_env_files) { + try writer.writeFieldID(28); + try writer.writeInt(@as(u8, @intFromBool(this.disable_default_env_files))); + } + try writer.endMessage(); } }; diff --git a/src/bun.js.zig b/src/bun.js.zig index 0d5710262f..1c79e41f64 100644 --- a/src/bun.js.zig +++ b/src/bun.js.zig @@ -135,7 +135,7 @@ pub const Run = struct { try @import("./bun.js/config.zig").configureTransformOptionsForBunVM(ctx.allocator, ctx.args), null, ); - try bundle.runEnvLoader(false); + try bundle.runEnvLoader(bundle.options.env.disable_default_env_files); const mini = jsc.MiniEventLoop.initGlobal(bundle.env, null); mini.top_level_dir = ctx.args.absolute_working_dir orelse ""; return bun.shell.Interpreter.initAndRunFromFile(ctx, mini, entry_path); diff --git a/src/bunfig.zig b/src/bunfig.zig index 6251f2f10f..312debfa12 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -148,6 +148,43 @@ pub const Bunfig = struct { } } + fn loadEnvConfig(this: *Parser, expr: js_ast.Expr) !void { + switch (expr.data) { + .e_null => { + // env = null -> disable default .env files + this.bunfig.disable_default_env_files = true; + }, + .e_boolean => |boolean| { + // env = false -> disable default .env files + // env = true -> keep default behavior (load .env files) + if (!boolean.value) { + this.bunfig.disable_default_env_files = true; + } + }, + .e_object => |obj| { + // env = { file: false } -> disable default .env files + if (obj.get("file")) |file_expr| { + switch (file_expr.data) { + .e_null => { + this.bunfig.disable_default_env_files = true; + }, + .e_boolean => |boolean| { + if (!boolean.value) { + this.bunfig.disable_default_env_files = true; + } + }, + else => { + try this.addError(file_expr.loc, "Expected 'file' to be a boolean or null"); + }, + } + } + }, + else => { + try this.addError(expr.loc, "Expected 'env' to be a boolean, null, or an object"); + }, + } + } + pub fn parse(this: *Parser, comptime cmd: Command.Tag) !void { bun.analytics.Features.bunfig += 1; @@ -191,6 +228,10 @@ pub const Bunfig = struct { this.bunfig.origin = try expr.data.e_string.string(allocator); } + if (json.get("env")) |env_expr| { + try this.loadEnvConfig(env_expr); + } + if (comptime cmd == .RunCommand or cmd == .AutoCommand) { if (json.get("serve")) |expr| { if (expr.get("port")) |port| { diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index 0ba62e79c3..efc50819a5 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -42,6 +42,7 @@ pub const ParamType = clap.Param(clap.Help); pub const base_params_ = (if (Environment.show_crash_trace) debug_params else [_]ParamType{}) ++ [_]ParamType{ clap.parseParam("--env-file ... Load environment variables from the specified file(s)") catch unreachable, + clap.parseParam("--no-env-file Disable automatic loading of .env files") catch unreachable, clap.parseParam("--cwd Absolute path to resolve files & entry points from. This just changes the process' cwd.") catch unreachable, clap.parseParam("-c, --config ? Specify path to Bun config file. Default $cwd/bunfig.toml") catch unreachable, clap.parseParam("-h, --help Display this menu and exit") catch unreachable, @@ -608,6 +609,10 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C opts.env_files = args.options("--env-file"); opts.extension_order = args.options("--extension-order"); + if (args.flag("--no-env-file")) { + opts.disable_default_env_files = true; + } + if (args.flag("--preserve-symlinks")) { opts.preserve_symlinks = true; } diff --git a/src/cli/exec_command.zig b/src/cli/exec_command.zig index 5bde215948..67b254ff47 100644 --- a/src/cli/exec_command.zig +++ b/src/cli/exec_command.zig @@ -8,7 +8,7 @@ pub const ExecCommand = struct { try @import("../bun.js/config.zig").configureTransformOptionsForBunVM(ctx.allocator, ctx.args), null, ); - try bundle.runEnvLoader(false); + try bundle.runEnvLoader(bundle.options.env.disable_default_env_files); var buf: bun.PathBuffer = undefined; const cwd = switch (bun.sys.getcwd(&buf)) { .result => |p| p, diff --git a/src/cli/run_command.zig b/src/cli/run_command.zig index 956dd9f851..ef4df2a3aa 100644 --- a/src/cli/run_command.zig +++ b/src/cli/run_command.zig @@ -814,6 +814,8 @@ pub const RunCommand = struct { } } + // Always skip default .env files for package.json script runner + // (see comment in env_loader.zig:542-548 - the script's own bun instance loads .env) this_transpiler.runEnvLoader(true) catch {}; } diff --git a/src/options.zig b/src/options.zig index 653cb30ecf..f131f9ddd9 100644 --- a/src/options.zig +++ b/src/options.zig @@ -2020,6 +2020,8 @@ pub const BundleOptions = struct { opts.env.files = transform.env_files; } + opts.env.disable_default_env_files = transform.disable_default_env_files; + if (transform.origin) |origin| { opts.origin = URL.parse(origin); } @@ -2225,6 +2227,9 @@ pub const Env = struct { /// List of explicit env files to load (e..g specified by --env-file args) files: []const []const u8 = &[_][]u8{}, + /// If true, disable loading of default .env files (from --no-env-file flag or bunfig) + disable_default_env_files: bool = false, + pub fn init( allocator: std.mem.Allocator, ) Env { diff --git a/src/transpiler.zig b/src/transpiler.zig index a532c57767..f3980e3dd9 100644 --- a/src/transpiler.zig +++ b/src/transpiler.zig @@ -528,7 +528,7 @@ pub const Transpiler = struct { this.options.env.prefix = "BUN_"; } - try this.runEnvLoader(false); + try this.runEnvLoader(this.options.env.disable_default_env_files); var is_production = this.env.isProduction(); diff --git a/test/cli/run/no-envfile.test.ts b/test/cli/run/no-envfile.test.ts new file mode 100644 index 0000000000..fd55d39264 --- /dev/null +++ b/test/cli/run/no-envfile.test.ts @@ -0,0 +1,265 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +test("--no-env-file disables .env loading", async () => { + using dir = tempDir("no-env-file", { + ".env": "FOO=bar", + "index.js": "console.log(process.env.FOO);", + }); + + // Without --no-env-file, .env should be loaded + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("bar"); + expect(exitCode).toBe(0); + } + + // With --no-env-file, .env should NOT be loaded + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-env-file", "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("undefined"); + expect(exitCode).toBe(0); + } +}); + +test("--no-env-file disables .env.local loading", async () => { + using dir = tempDir("no-env-file-local", { + ".env": "FOO=bar", + ".env.local": "FOO=local", + "index.js": "console.log(process.env.FOO);", + }); + + // Without --no-env-file, .env.local should override .env + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("local"); + expect(exitCode).toBe(0); + } + + // With --no-env-file, neither should be loaded + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-env-file", "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("undefined"); + expect(exitCode).toBe(0); + } +}); + +test("--no-env-file disables .env.development.local loading", async () => { + using dir = tempDir("no-env-file-dev-local", { + ".env": "FOO=bar", + ".env.development.local": "FOO=dev-local", + "index.js": "console.log(process.env.FOO);", + }); + + // Without --no-env-file, .env.development.local should be loaded + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("dev-local"); + expect(exitCode).toBe(0); + } + + // With --no-env-file, it should NOT be loaded + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-env-file", "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("undefined"); + expect(exitCode).toBe(0); + } +}); + +test("bunfig env.file = false disables .env loading", async () => { + using dir = tempDir("bunfig-env-file-false", { + ".env": "FOO=bar", + "bunfig.toml": ` +[env] +file = false +`, + "index.js": "console.log(process.env.FOO);", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("undefined"); + expect(exitCode).toBe(0); +}); + +test("bunfig env = false disables .env loading", async () => { + using dir = tempDir("bunfig-env-false", { + ".env": "FOO=bar", + "bunfig.toml": ` +env = false +`, + "index.js": "console.log(process.env.FOO);", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("undefined"); + expect(exitCode).toBe(0); +}); + +test("--no-env-file with -e flag", async () => { + using dir = tempDir("no-env-file-eval", { + ".env": "FOO=bar", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-env-file", "-e", "console.log(process.env.FOO)"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("undefined"); + expect(exitCode).toBe(0); +}); + +test("--no-env-file combined with --env-file still loads explicit file", async () => { + using dir = tempDir("no-env-file-with-env-file", { + ".env": "FOO=bar", + ".env.custom": "FOO=custom", + "index.js": "console.log(process.env.FOO);", + }); + + // --no-env-file should skip .env but --env-file should load .env.custom + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-env-file", "--env-file", ".env.custom", "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("custom"); + expect(exitCode).toBe(0); +}); + +test("bunfig env = true still loads .env files", async () => { + using dir = tempDir("bunfig-env-true", { + ".env": "FOO=bar", + "bunfig.toml": ` +env = true +`, + "index.js": "console.log(process.env.FOO);", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("bar"); + expect(exitCode).toBe(0); +}); + +test("--no-env-file in production mode", async () => { + using dir = tempDir("no-env-file-production", { + ".env": "FOO=bar", + ".env.production": "FOO=prod", + "index.js": "console.log(process.env.FOO);", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-env-file", "index.js"], + env: { ...bunEnv, NODE_ENV: "production" }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("undefined"); + expect(exitCode).toBe(0); +});