Skip to content

Conversation

trevordcampbell
Copy link

Summary

  • Fixes: Bun autoloading of dotenv can override Varlock’s resolved values, causing “development” to leak into non-dev runs.
  • Approach: Make Varlock the single source of truth for env resolution and neutralize Bun’s dotenv via --env-file, while preserving runtime-agnostic semantics and keeping UX simple.
  • Result: Works for any @envFlag value/name, is zero-config for most users, and remains portable to other runtimes.

References:


Why this bug happens under Bun

Bun auto-loads dotenv files based on NODE_ENV (in precedence order): .env.env.(development|production|test).env.local. It does not recognize custom names (e.g., .env.staging). See Bun environment variables.

Varlock’s model resolves from .env.schema + .env.<@envFlag> (e.g., APP_ENV) regardless of NODE_ENV. When Bun autoloads before or alongside Varlock, values from .env.development and/or .env.local can override the intended values, especially when NODE_ENV is defaulting to development. This is the core of #139.


Why a Varlock-core fix was proposed instead of a Bun plugin

  • Deterministic and supported: Bun’s --env-file deterministically controls dotenv behavior; passing an empty file results in no autoload. This is stable and documented in Bun environment variables.
  • Zero-config UX: Users keep using varlock load / varlock run. No per-project preloads or bunfig changes.
  • Ordering guarantees: Injecting Varlock’s resolved env into the spawned process happens at process creation time—so early module init sees the intended values.
  • Runtime-agnostic and maintainable: Works with Bun today, and the same pattern can adapt to other runtimes that autoload env files. Less coupling to Bun internals than a plugin. See #140.

What changed (code and behavior)

  • Harden resolver to ignore ambient process.env for schema-defined keys by default

    • File: packages/varlock/env-graph/lib/config-item.ts
    • Gate process.env overrides via EnvGraph.respectExistingEnv:
      • If an item exists in the schema, ambient process.env will not override it unless respectExistingEnv is true.
      • Special case: allow the @envFlag key (e.g., APP_ENV) to be set from process.env during the early pass so Varlock can select the correct .env.<env> files from the start.
    • Effect: Prevents Bun’s preloaded values from clobbering schema-defined keys, without blocking setting the environment flag via process.env.
  • Default handling for .env.local is opt-out

    • File: packages/varlock/env-graph/lib/loader.ts
    • We include .env.local and .env.<env>.local by default (consistent with existing Varlock behavior).
    • Added excludeLocal?: boolean to disable locals when explicitly requested.
    • Propagation to CLI:
      • packages/varlock/src/lib/load-graph.ts plumbs excludeLocal and respectExistingEnv.
      • packages/varlock/src/cli/commands/load.command.ts adds flags:
        • --exclude-local: Exclude .env.local and .env.[env].local
        • --respect-existing-env: Allow ambient process.env to override schema-defined keys
      • packages/varlock/src/cli/commands/run.command.ts mirrors the same flags for the run path.
  • Bun-aware runner

    • File: packages/varlock/src/cli/commands/run.command.ts
    • Detects bun/bunx and neutralizes Bun dotenv by passing an empty --env-file.
    • Injects Varlock’s resolved environment into the child process using a hardened pass-through:
      • Default: whitelist essential OS vars (PATH, HOME, SHELL, TERM, TZ, LANG, LC_ALL, PWD, TMPDIR, TEMP, TMP) + all Varlock-resolved keys.
      • This minimizes ambient leakage and avoids accidental overrides by pre-existing parent env keys.
    • Optional --bun-sync-node-env: sets NODE_ENV to the resolved @envFlag value when enabled. Off by default to avoid surprises.
    • Notes:
      • Resolver strictness and .env.local defaults apply to all commands (Node or Bun).
      • Bun dotenv neutralization only applies when the child command is bun or bunx.

Backward compatibility and UX

  • import { ENV } from 'varlock/env' still works

    • Runtime autoload flows (varlock/auto-load) are unchanged. ENV is still initialized from process.env.__VARLOCK_ENV, injected by varlock run or integrations, and exposes resolved values to application code.
  • Default behavior changes you should be aware of

    • Ambient overrides: By default, ambient process.env values no longer override schema-defined keys. Restore old behavior with --respect-existing-env (or a future .varlockrc default).
    • .env.local: Included by default, consistent with the current Varlock mental model. You can explicitly opt-out via --exclude-local.
    • Bun-only: When the child command is Bun/Bunx, varlock run disables Bun dotenv to ensure no drift from Varlock’s resolved env.
  • Performance

    • No material changes in the core resolution performance. The whitelist-based env pass-through is trivial overhead.

How it works (step-by-step)

  1. Resolution
  • Varlock loads .env.* files in the project directory, parses .env.schema, discovers @envFlag (e.g., APP_ENV).
  • During an early pass, the env flag key is resolved. If APP_ENV is defined in the ambient environment, it is respected for this key only to select the appropriate .env.<env> files.
  • Schema-defined keys are then resolved from .env.schema, the environment-specific file(s), and optionally .env.local per flag. Ambient process.env is ignored for these keys by default (unless --respect-existing-env is set).
  1. Running with Bun
  • If varlock run -- bun ... or varlock run -- bunx ...:
    • Varlock spawns Bun with --env-file <empty-temp-file>, neutralizing Bun’s dotenv mechanism.
    • Varlock injects the resolved env and a small whitelist of parent env vars into the child process, ensuring libraries see the intended values at module init.
    • Optionally syncs NODE_ENV to the resolved @envFlag value if --bun-sync-node-env is provided.

New Flags and defaults

  • --exclude-local: Exclude .env.local and .env.[env].local. Default: not excluded (included).
  • --respect-existing-env: Allow ambient process.env to override schema-defined keys. Default: false.
  • --bun-sync-node-env: When running Bun, set NODE_ENV to the resolved @envFlag value. Default: false.

Code references (selected)

  • Resolver hardening and env flag special-case

    • packages/varlock/env-graph/lib/config-item.ts:
      • Applies process.env overrides only when respectExistingEnv is true or the item is not in the schema.
      • Always allows process.env to set the env flag key (e.g., APP_ENV) during the early pass so .env.<env> selection works.
  • .env file loading, opt-out .env.local

    • packages/varlock/env-graph/lib/loader.ts:
      • Discovers .env.* files.
      • Includes locals by default; disables them only if excludeLocal === true.
  • CLI flags

    • packages/varlock/src/cli/commands/load.command.ts: --exclude-local, --respect-existing-env, --env.
    • packages/varlock/src/cli/commands/run.command.ts: --exclude-local, --respect-existing-env, --bun-sync-node-env, --env.
  • Bun-aware runner

    • packages/varlock/src/cli/commands/run.command.ts:
      • Detects bun/bunx.
      • Prepends --env-file <empty-temp-file>.
      • Constructs a whitelist pass-through env for the child process.
      • Optional NODE_ENV sync from @envFlag.

Testing instructions

A ready-to-run Bun-based DevContainer test project (with updated executables) is available for reviewers:

varlock-testing DevContainer

It contains:

  • A set of .env* files (.env.schema, .env.production, .env.development, .env.local, etc.)
  • A simple script.ts that logs selected variables using process.env (do not import varlock/env for this specific test path).

Uses an SEA bundle of the modified Varlock CLI (no install required in the test project):

  • From project root, run:

Basic resolution (JSON):

bun ./varlock-cli-executable-bun-fix.cjs load --format json

Run a Bun script with dotenv neutralized:

bun ./varlock-cli-executable-bun-fix.cjs run -- bun script.ts

Select staging env (env flag via ambient):

APP_ENV=staging bun ./varlock-cli-executable-bun-fix.cjs -- bun script.ts

Opt-out .env.local:

APP_ENV=production bun ./varlock-cli-executable-bun-fix.cjs run --exclude-local -- bun script.ts

Allow ambient process.env to override schema-defined keys (off by default):

OVERRIDE=from-ambient APP_ENV=production bun ./varlock-cli-executable-bun-fix.cjs load --respect-existing-env --format json | jq .OVERRIDE

Sync NODE_ENV to your @envFlag value (optional):

APP_ENV=production bun ./varlock-cli-executable-bun-fix.cjs run --bun-sync-node-env -- bun script.ts

Expected behavior:

  • With APP_ENV=production, values come from .env.schema + .env.production (+ .env.local unless excluded).
  • With APP_ENV=staging, values come from .env.schema + .env.staging (+ .env.local unless excluded).
  • OVERRIDE only comes from ambient when --respect-existing-env is used.
  • Bun dotenv does not alter results when running through varlock run.

Maintainability and portability

  • Runtime-agnostic: The pattern (resolve first, neutralize external dotenv, inject resolved env) can be applied to other CLIs/runtimes that auto-load dotenv.
  • Minimal surface area: Lives inside Varlock’s CLI and resolver core. No per-project plugin/preload to maintain.
  • Safe-by-default: Whitelist pass-through for parent env reduces leakage; explicit flags control deviations.
  • Clear escape hatches: --respect-existing-env, --exclude-local, --bun-sync-node-env.

Compatibility notes and migration guidance

  • Ambient overrides for schema-defined keys now default to “off.”
    • If you rely on ambient overrides, add --respect-existing-env to your CLI invocations (or adopt a config default when available).
  • .env.local remains included by default for local dev parity. Use --exclude-local if you want to disable it for specific runs.

Acknowledgements and prior art

  • This PR addresses #139, which documents the Bun autoload override problem.
  • It considers and chooses a core Varlock fix over a Bun plugin as initially suggested in #140 for the reasons outlined above.
  • References:

Proposed .varlockrc (optional, future docs)

{
  "resolver": {
    "respectExistingEnv": false
  },
  "bun": {
    "dotenv": "none",
    "syncNodeEnv": false
  }
}

This example captures the defaults introduced in this PR and shows how a future .varlockrc could be used to centralize project-wide preferences. We can add this to the docs as a forward-looking example.

Copy link

changeset-bot bot commented Aug 30, 2025

⚠️ No Changeset found

Latest commit: 77be699

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant