diff --git a/completions/bun.bash b/completions/bun.bash index e77f80db5f..53d10aad94 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -84,7 +84,7 @@ _bun_completions() { local SUBCOMMANDS="dev bun create run install add remove upgrade completions discord help init pm x"; - GLOBAL_OPTIONS[LONG_OPTIONS]="--use --cwd --bunfile --server-bunfile --config --disable-react-fast-refresh --disable-hmr --extension-order --jsx-factory --jsx-fragment --extension-order --jsx-factory --jsx-fragment --jsx-import-source --jsx-production --jsx-runtime --main-fields --no-summary --version --platform --public-dir --tsconfig-override --define --external --help --inject --loader --origin --port --dump-environment-variables --dump-limits --disable-bun-js"; + GLOBAL_OPTIONS[LONG_OPTIONS]="--use --cwd --bunfile --server-bunfile --config --disable-react-fast-refresh --disable-hmr --env-file --extension-order --jsx-factory --jsx-fragment --extension-order --jsx-factory --jsx-fragment --jsx-import-source --jsx-production --jsx-runtime --main-fields --no-summary --version --platform --public-dir --tsconfig-override --define --external --help --inject --loader --origin --port --dump-environment-variables --dump-limits --disable-bun-js"; GLOBAL_OPTIONS[SHORT_OPTIONS]="-c -v -d -e -h -i -l -u -p"; PACKAGE_OPTIONS[ADD_OPTIONS_LONG]="--development --optional"; diff --git a/completions/bun.zsh b/completions/bun.zsh index 2381b857c5..46485f39ba 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -406,6 +406,7 @@ _bun_run_completion() { '--cwd[Absolute path to resolve files & entry points from. This just changes the process cwd]:cwd' \ '--config[Config file to load bun from (e.g. -c bunfig.toml]: :->config' \ '-c[Config file to load bun from (e.g. -c bunfig.toml]: :->config' \ + '--env-file[Load environment variables from the specified file(s)]:env-file' \ '--extension-order[Defaults to: .tsx,.ts,.jsx,.js,.json]:extension-order' \ '--jsx-factory[Changes the function called when compiling JSX elements using the classic JSX runtime]:jsx-factory' \ '--jsx-fragment[Changes the function called when compiling JSX fragments]:jsx-fragment' \ @@ -572,6 +573,7 @@ _bun_test_completion() { '--cwd[Set a specific cwd]:cwd' \ '-c[Load config(bunfig.toml)]: :->config' \ '--config[Load config(bunfig.toml)]: :->config' \ + '--env-file[Load environment variables from the specified file(s)]:env-file' \ '--extension-order[Defaults to: .tsx,.ts,.jsx,.js,.json]:extension-order' \ '--jsx-factory[Changes the function called when compiling JSX elements using the classic JSX runtime]:jsx-factory' \ '--jsx-fragment[Changes the function called when compiling JSX fragments]:jsx-fragment' \ diff --git a/completions/spec.yaml b/completions/spec.yaml index a07c5c797d..be8cda9fee 100644 --- a/completions/spec.yaml +++ b/completions/spec.yaml @@ -78,6 +78,9 @@ subcommands: - name: server-bunfile type: string summary: "Use a specific .bun file for SSR in bun dev (default: node_modules.server.bun)" + - name: env-file + type: string + summary: "Load environment variables from the specified file(s)" - name: extension-order type: string summary: "defaults to: .tsx,.ts,.jsx,.js,.json" diff --git a/src/api/schema.zig b/src/api/schema.zig index 79a98cfa8a..5f6a5bffc7 100644 --- a/src/api/schema.zig +++ b/src/api/schema.zig @@ -1729,6 +1729,9 @@ pub const Api = struct { /// serve serve: ?bool = null, + /// env_files + env_files: []const []const u8, + /// extension_order extension_order: []const []const u8, diff --git a/src/bundler.zig b/src/bundler.zig index 479843bcd9..1359cfa7de 100644 --- a/src/bundler.zig +++ b/src/bundler.zig @@ -474,11 +474,11 @@ pub const Bundler = struct { } if (!has_production_env and this.options.isTest()) { - try this.env.load(dir, .@"test"); + try this.env.load(dir, this.options.env.files, .@"test"); } else if (this.options.production) { - try this.env.load(dir, .production); + try this.env.load(dir, this.options.env.files, .production); } else { - try this.env.load(dir, .development); + try this.env.load(dir, this.options.env.files, .development); } }, .disable => { diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 8e145aa344..79a7e72d65 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -1609,6 +1609,7 @@ pub const BundleV2 = struct { .external = config.external.keys(), .main_fields = &.{}, .extension_order = &.{}, + .env_files = &.{}, }, completion.env, ); diff --git a/src/cli.zig b/src/cli.zig index a4effd042b..afff8bc2e6 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -170,6 +170,7 @@ pub const Arguments = struct { clap.parseParam("-b, --bun Force a script or package to use Bun's runtime instead of Node.js (via symlinking node)") 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 ? Config file to load Bun from (e.g. -c bunfig.toml") catch unreachable, + clap.parseParam("--env-file ... Load environment variables from the specified file(s)") catch unreachable, clap.parseParam("--extension-order ... Defaults to: .tsx,.ts,.jsx,.js,.json ") catch unreachable, clap.parseParam("--jsx-factory Changes the function called when compiling JSX elements using the classic JSX runtime") catch unreachable, clap.parseParam("--jsx-fragment Changes the function called when compiling JSX fragments") catch unreachable, @@ -526,6 +527,7 @@ pub const Arguments = struct { opts.main_fields = args.options("--main-fields"); // we never actually supported inject. // opts.inject = args.options("--inject"); + opts.env_files = args.options("--env-file"); opts.extension_order = args.options("--extension-order"); ctx.passthrough = args.remaining(); diff --git a/src/cli/run_command.zig b/src/cli/run_command.zig index b63d169c41..9b855156ef 100644 --- a/src/cli/run_command.zig +++ b/src/cli/run_command.zig @@ -543,9 +543,9 @@ pub const RunCommand = struct { if (root_dir_info.getEntries(0)) |dir| { // Run .env again if it exists in a parent dir if (this_bundler.options.production) { - this_bundler.env.load(dir, .production) catch {}; + this_bundler.env.load(dir, this_bundler.options.env.files, .production) catch {}; } else { - this_bundler.env.load(dir, .development) catch {}; + this_bundler.env.load(dir, this_bundler.options.env.files, .development) catch {}; } } } diff --git a/src/env_loader.zig b/src/env_loader.zig index 2e18e5cca5..36e85e01f0 100644 --- a/src/env_loader.zig +++ b/src/env_loader.zig @@ -18,6 +18,12 @@ const URL = @import("./url.zig").URL; const Api = @import("./api/schema.zig").Api; const which = @import("./which.zig").which; +const DotEnvFileSuffix = enum { + development, + production, + @"test", +}; + pub const Loader = struct { map: *Map, allocator: std.mem.Allocator, @@ -31,6 +37,9 @@ pub const Loader = struct { @".env.test.local": ?logger.Source = null, @".env": ?logger.Source = null, + // only populated with files specified explicitely (e.g. --env-file arg) + custom_files_loaded: std.StringArrayHashMap(logger.Source), + quiet: bool = false, did_load_process: bool = false, @@ -359,6 +368,7 @@ pub const Loader = struct { return Loader{ .map = map, .allocator = allocator, + .custom_files_loaded = std.StringArrayHashMap(logger.Source).init(allocator), }; } @@ -396,16 +406,52 @@ pub const Loader = struct { std.mem.doNotOptimizeAway(&source); } + pub fn load( + this: *Loader, + dir: *Fs.FileSystem.DirEntry, + env_files: []const []const u8, + comptime suffix: DotEnvFileSuffix, + ) !void { + const start = std.time.nanoTimestamp(); + + if (env_files.len > 0) { + try this.loadExplicitFiles(env_files); + } else { + try this.loadDefaultFiles(dir, suffix); + } + + if (!this.quiet) this.printLoaded(start); + } + + fn loadExplicitFiles( + this: *Loader, + env_files: []const []const u8, + ) !void { + // iterate backwards, so the latest entry in the latest arg instance assumes the highest priority + var i: usize = env_files.len; + while (i > 0) : (i -= 1) { + var arg_value = std.mem.trim(u8, env_files[i - 1], " "); + if (arg_value.len > 0) { // ignore blank args + var iter = std.mem.splitBackwardsScalar(u8, arg_value, ','); + while (iter.next()) |file_path| { + if (file_path.len > 0) { + try this.loadEnvFileDynamic(file_path, false, true); + Analytics.Features.dotenv = true; + } + } + } + } + } + // .env.local goes first // Load .env.development if development // Load .env.production if !development // .env goes last - pub fn load( + fn loadDefaultFiles( this: *Loader, dir: *Fs.FileSystem.DirEntry, - comptime suffix: enum { development, production, @"test" }, + comptime suffix: DotEnvFileSuffix, ) !void { - const start = std.time.nanoTimestamp(); var dir_handle: std.fs.Dir = std.fs.cwd(); switch (comptime suffix) { @@ -461,8 +507,6 @@ pub const Loader = struct { try this.loadEnvFile(dir_handle, ".env", false, false); Analytics.Features.dotenv = true; } - - if (!this.quiet) this.printLoaded(start); } pub fn printLoaded(this: *Loader, start: i128) void { @@ -474,7 +518,8 @@ pub const Loader = struct { @as(u8, @intCast(@intFromBool(this.@".env.development" != null))) + @as(u8, @intCast(@intFromBool(this.@".env.production" != null))) + @as(u8, @intCast(@intFromBool(this.@".env.test" != null))) + - @as(u8, @intCast(@intFromBool(this.@".env" != null))); + @as(u8, @intCast(@intFromBool(this.@".env" != null))) + + this.custom_files_loaded.count(); if (count == 0) return; const elapsed = @as(f64, @floatFromInt((std.time.nanoTimestamp() - start))) / std.time.ns_per_ms; @@ -514,6 +559,17 @@ pub const Loader = struct { } } } + + var iter = this.custom_files_loaded.iterator(); + while (iter.next()) |e| { + loaded_i += 1; + if (count == 1 or (loaded_i >= count and count > 1)) { + Output.prettyError("\"{s}\"", .{e.key_ptr.*}); + } else { + Output.prettyError("\"{s}\", ", .{e.key_ptr.*}); + } + } + Output.prettyErrorln("\n", .{}); Output.flush(); } @@ -606,6 +662,78 @@ pub const Loader = struct { @field(this, base) = source; } + + pub fn loadEnvFileDynamic( + this: *Loader, + file_path: []const u8, + comptime override: bool, + comptime conditional: bool, + ) !void { + if (this.custom_files_loaded.contains(file_path)) { + return; + } + + var file = bun.openFile(file_path, .{ .mode = .read_only }) catch { + // prevent retrying + try this.custom_files_loaded.put(file_path, logger.Source.initPathString(file_path, "")); + return; + }; + defer file.close(); + + const end = brk: { + if (comptime Environment.isWindows) { + const pos = try file.getEndPos(); + if (pos == 0) { + try this.custom_files_loaded.put(file_path, logger.Source.initPathString(file_path, "")); + return; + } + + break :brk pos; + } + + const stat = try file.stat(); + + if (stat.size == 0 or stat.kind != .file) { + try this.custom_files_loaded.put(file_path, logger.Source.initPathString(file_path, "")); + return; + } + + break :brk stat.size; + }; + + var buf = try this.allocator.alloc(u8, end + 1); + errdefer this.allocator.free(buf); + const amount_read = file.readAll(buf[0..end]) catch |err| switch (err) { + error.Unexpected, error.SystemResources, error.OperationAborted, error.BrokenPipe, error.AccessDenied, error.IsDir => { + if (!this.quiet) { + Output.prettyErrorln("{s} error loading {s} file", .{ @errorName(err), file_path }); + } + + // prevent retrying + try this.custom_files_loaded.put(file_path, logger.Source.initPathString(file_path, "")); + return; + }, + else => { + return err; + }, + }; + + // The null byte here is mostly for debugging purposes. + buf[end] = 0; + + const source = logger.Source.initPathString(file_path, buf[0..amount_read]); + + Parser.parse( + &source, + this.allocator, + this.map, + override, + false, + conditional, + ); + + try this.custom_files_loaded.put(file_path, source); + } }; const Parser = struct { diff --git a/src/install/install.zig b/src/install/install.zig index b4650cb75f..0b9d086133 100644 --- a/src/install/install.zig +++ b/src/install/install.zig @@ -5842,7 +5842,7 @@ pub const PackageManager = struct { }; env.loadProcess(); - try env.load(entries_option.entries, .production); + try env.load(entries_option.entries, &[_][]u8{}, .production); if (env.map.get("BUN_INSTALL_VERBOSE") != null) { PackageManager.verbose_install = true; diff --git a/src/install/lockfile.zig b/src/install/lockfile.zig index 9167dc611f..a57a6f5bea 100644 --- a/src/install/lockfile.zig +++ b/src/install/lockfile.zig @@ -1076,7 +1076,7 @@ pub const Printer = struct { }; env_loader.loadProcess(); - try env_loader.load(entries_option.entries, .production); + try env_loader.load(entries_option.entries, &[_][]u8{}, .production); var log = logger.Log.init(allocator); try options.load( allocator, diff --git a/src/options.zig b/src/options.zig index 3dd733c697..4a77f2e955 100644 --- a/src/options.zig +++ b/src/options.zig @@ -1607,6 +1607,10 @@ pub const BundleOptions = struct { Analytics.Features.define = Analytics.Features.define or transform.define != null; Analytics.Features.loaders = Analytics.Features.loaders or transform.loaders != null; + if (transform.env_files.len > 0) { + opts.env.files = transform.env_files; + } + if (transform.origin) |origin| { opts.origin = URL.parse(origin); } @@ -2277,6 +2281,9 @@ pub const Env = struct { defaults: List = List{}, allocator: std.mem.Allocator = undefined, + /// List of explicit env files to load (e..g specified by --env-file args) + files: []const []const u8 = &[_][]u8{}, + pub fn init( allocator: std.mem.Allocator, ) Env { diff --git a/test/cli/run/env.test.ts b/test/cli/run/env.test.ts index 6d210cb6aa..7ab71e9562 100644 --- a/test/cli/run/env.test.ts +++ b/test/cli/run/env.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { bunRun, bunRunAsScript, bunTest, tempDirWithFiles, bunExe, bunEnv } from "harness"; import path from "path"; @@ -462,3 +462,100 @@ describe("boundary tests", () => { expect(stdout2).toBe(expected); }); }); + +describe("--env-file", () => { + let dir = ""; + beforeAll(() => { + dir = tempDirWithFiles("dotenv-arg", { + ".env": "BUNTEST_DOTENV=1", + ".env.a": "BUNTEST_A=1", + ".env.b": "BUNTEST_B=1", + ".env.c": "BUNTEST_C=1", + ".env.a2": "BUNTEST_A=2", + ".env.invalid": + "BUNTEST_A=1\nBUNTEST_B =1\n BUNTEST_C = 1 \n...BUNTEST_invalid1\nBUNTEST_invalid2\nBUNTEST_D=\nBUNTEST_E=1", + "subdir/.env.s": "BUNTEST_S=1", + "index.ts": + "console.log(Object.entries(process.env).flatMap(([k, v]) => k.startsWith('BUNTEST_') ? [`${k}=${v}`] : []).sort().join(','));", + }); + }); + + function bunRun(bunArgs: string[], envOverride?: Record) { + const file = `${dir}/index.ts`; + const result = Bun.spawnSync([bunExe(), ...bunArgs, file], { + cwd: path.dirname(file), + env: { + ...bunEnv, + NODE_ENV: undefined, + ...envOverride, + }, + }); + if (!result.success) throw new Error(result.stderr.toString("utf8")); + return { + stdout: result.stdout.toString("utf8").trim(), + stderr: result.stderr.toString("utf8").trim(), + }; + } + + test("single arg", () => { + expect(bunRun(["--env-file", ".env.a"]).stdout).toBe("BUNTEST_A=1"); + expect(bunRun(["--env-file=.env.a"]).stdout).toBe("BUNTEST_A=1"); + }); + + test("multiple args", () => { + expect(bunRun(["--env-file", ".env.a", "--env-file=.env.b"]).stdout).toBe("BUNTEST_A=1,BUNTEST_B=1"); + }); + + test("single arg with multiple files", () => { + expect(bunRun(["--env-file", ".env.a,.env.b,.env.c"]).stdout).toBe("BUNTEST_A=1,BUNTEST_B=1,BUNTEST_C=1"); + }); + + test("priority on multi-file single arg", () => { + expect(bunRun(["--env-file", ".env.a,.env.a2"]).stdout).toBe("BUNTEST_A=2"); + }); + + test("priority on multiple args", () => { + expect(bunRun(["--env-file", ".env.a", "--env-file", ".env.a2"]).stdout).toBe("BUNTEST_A=2"); + }); + + test("priority on process env", () => { + expect( + bunRun(["--env-file=.env.a", "--env-file=.env.b"], { + BUNTEST_PROCESS: "P", + BUNTEST_A: "P", + }).stdout, + ).toBe("BUNTEST_A=P,BUNTEST_B=1,BUNTEST_PROCESS=P"); + }); + + test("absolute filepath", () => { + expect(bunRun(["--env-file", `${dir}/.env.a`]).stdout).toBe("BUNTEST_A=1"); + }); + + test("explicit relative filepath", () => { + expect(bunRun(["--env-file", "./.env.a"]).stdout).toBe("BUNTEST_A=1"); + }); + + test("subdirectory filepath", () => { + expect(bunRun(["--env-file", "subdir/.env.s"]).stdout).toBe("BUNTEST_S=1"); + expect(bunRun(["--env-file", "./subdir/.env.s"]).stdout).toBe("BUNTEST_S=1"); + }); + + test("when arg missing, fallback to default dotenv behavior", () => { + // if --env-file missing, it should fallback to the default builtin behavior (.env, .env.production, etc.) + expect(bunRun([]).stdout).toBe("BUNTEST_DOTENV=1"); + }); + + test("empty string disables default dotenv behavior", () => { + expect(bunRun(["--env-file=''"]).stdout).toBe(""); + }); + + test("should correctly ignore invalid values and parse the rest", () => { + const res = bunRun(["--env-file=.env.invalid"]); + expect(res.stdout).toBe("BUNTEST_A=1,BUNTEST_B=1,BUNTEST_C=1,BUNTEST_D=,BUNTEST_E=1"); + }); + + test("should ignore a file that doesn't exist", () => { + const res = bunRun(["--env-file=.env.nonexisting"]); + expect(res.stdout).toBe(""); + }); +});