diff --git a/.changeset/vite-env-var.md b/.changeset/vite-env-var.md
new file mode 100644
index 0000000..bef49d5
--- /dev/null
+++ b/.changeset/vite-env-var.md
@@ -0,0 +1,19 @@
+---
+"@vlandoss/env": minor
+---
+
+The `envConfig()` Vite plugin can now select the env from a `VITE_ENV` env var instead of requiring `--mode`.
+
+The plugin reads `VITE_ENV` from `process.env` **and** your `.env*` files (via Vite's `loadEnv`, so inline/shell values win over file values) and uses it to pick the per-env `#config` file and the `__ENV_NAME__` build constant. When `VITE_ENV` is unset or empty it falls back to Vite's `mode`, so `vite build --mode staging` keeps working unchanged — this is purely additive.
+
+```bash
+# These are now equivalent:
+VITE_ENV=staging vite build
+vite build --mode staging
+```
+
+The var name is configurable with the new `envVar` option (default `"VITE_ENV"`):
+
+```ts
+envConfig({ envVar: "APP_ENV" });
+```
diff --git a/docsite/content/docs/api-reference/vite.mdx b/docsite/content/docs/api-reference/vite.mdx
index 3b1ab53..3d22a8c 100644
--- a/docsite/content/docs/api-reference/vite.mdx
+++ b/docsite/content/docs/api-reference/vite.mdx
@@ -12,9 +12,20 @@ The `@vlandoss/env/vite` entrypoint exposes a single Vite plugin. It does two th
| ------------ | -------- | ------------------------------------------------------------------------------------ |
| `envConfig` | Function | Returns a Vite plugin. Adds the `#config` alias and the `__ENV_NAME__` define. |
+## `envConfig(options?)`
+
+The env name the plugin keys off comes from `VITE_ENV` (read from `process.env` and your `.env*` files), falling back to Vite's `mode` when unset — so you can pick the env with `VITE_ENV=staging vite build` instead of `--mode staging`. See [Custom modes → Selecting the env without `--mode`](/docs/guides/custom-modes#selecting-the-env-without---mode).
+
+`options` (all optional):
+
+| Option | Type | Default | Summary |
+| -------- | -------- | ---------------- | ------------------------------------------------------------------------------------------- |
+| `alias` | `string` | `"#config"` | Import specifier the matched per-env config file is exposed as. |
+| `cwd` | `string` | `process.cwd()` | Base directory for config discovery and `.env*` loading. |
+| `envVar` | `string` | `"VITE_ENV"` | Env var that selects the env name. Falls back to Vite's `mode` when unset or empty. |
+
- Full signatures and option tables are coming soon. Until then, see
- [Guides → SPA — Vite plugin](/docs/guides/spa-vite-plugin) and
+ See [Guides → SPA — Vite plugin](/docs/guides/spa-vite-plugin) and
[Guides → Custom modes](/docs/guides/custom-modes) for the wiring.
diff --git a/docsite/content/docs/concepts/env-name.mdx b/docsite/content/docs/concepts/env-name.mdx
index 8625dd8..16515bf 100644
--- a/docsite/content/docs/concepts/env-name.mdx
+++ b/docsite/content/docs/concepts/env-name.mdx
@@ -11,7 +11,7 @@ icon: Hash
First defined wins:
1. **`env.ENV`** — explicit runtime override (always wins). Use this in CI, tests, or scripts.
-2. **`__ENV_NAME__`** — build-time literal injected by the `envConfig()` Vite plugin. Beats `NODE_ENV` because Vite forces `NODE_ENV="production"` regardless of `--mode`, so this is the only way `envName()` can return custom modes like `staging` / `qa` in the browser.
+2. **`__ENV_NAME__`** — build-time literal injected by the `envConfig()` Vite plugin (the env it built for: `VITE_ENV` if set, otherwise Vite's `mode`). Beats `NODE_ENV` because Vite forces `NODE_ENV="production"` regardless of `--mode`, so this is the only way `envName()` can return custom envs like `staging` / `qa` in the browser.
3. **`env.NODE_ENV`**
4. **`env.VITE_ENV`**
5. **`"development"`** — fallback.
@@ -30,7 +30,7 @@ envName({ NODE_ENV: "test", ENV: "qa" }); // -> "qa"
If you run `vite build --mode staging`, Vite **still sets `NODE_ENV="production"`** at build time. Without the `envConfig()` plugin, `envName()` in browser code would return `"production"` — not `"staging"`.
-The plugin solves this by injecting `__ENV_NAME__ = "staging"` as a build-time constant. That constant wins over `NODE_ENV` in the precedence chain, so the browser sees the mode you actually built.
+The plugin solves this by injecting `__ENV_NAME__ = "staging"` as a build-time constant (from `--mode staging` or `VITE_ENV=staging`). That constant wins over `NODE_ENV` in the precedence chain, so the browser sees the env you actually built.
This is why **the plugin is required for custom Vite modes**, even if you load config via dynamic import (Pattern 1) and don't actually use the `#config` alias.
diff --git a/docsite/content/docs/guides/custom-modes.mdx b/docsite/content/docs/guides/custom-modes.mdx
index 5d4cb1b..b8c1c4d 100644
--- a/docsite/content/docs/guides/custom-modes.mdx
+++ b/docsite/content/docs/guides/custom-modes.mdx
@@ -46,6 +46,29 @@ src/
The plugin's discovery scans `[src/]config/.{ts,mts,cts,js,mjs,cjs,json}` and pulls in the right one — same algorithm as `loadConfig` in `@vlandoss/env/fs`.
+## Selecting the env without `--mode`
+
+If you'd rather not thread `--mode` through every command, set a `VITE_ENV` env var instead. The plugin reads it from `process.env` **and** your `.env*` files (via Vite's `loadEnv`, so an inline/shell value wins over a file value) and uses it to pick the config file and `__ENV_NAME__`. These are equivalent:
+
+```bash title="terminal"
+VITE_ENV=staging vite build
+vite build --mode staging
+```
+
+`VITE_ENV` takes precedence; when it's unset or empty the plugin falls back to Vite's `mode`, so `--mode` keeps working unchanged. Put it in a `.env` file to make it the default for a project:
+
+```dotenv title=".env.staging"
+VITE_ENV=staging
+```
+
+Rename the var with the `envVar` option if `VITE_ENV` clashes with something:
+
+```ts title="vite.config.ts"
+export default defineConfig({
+ plugins: [envConfig({ envVar: "APP_ENV" })],
+});
+```
+
## Verifying it works
After a `vite build --mode staging`, anywhere in browser code:
diff --git a/docsite/content/docs/guides/spa-vite-plugin.mdx b/docsite/content/docs/guides/spa-vite-plugin.mdx
index 3686966..6c83b0c 100644
--- a/docsite/content/docs/guides/spa-vite-plugin.mdx
+++ b/docsite/content/docs/guides/spa-vite-plugin.mdx
@@ -8,7 +8,7 @@ icon: Boxes
- Pure SPA built with Vite.
- You want **each build artifact to ship only its env's config** — other env files must not be present in the deployment at all.
-- You're OK with one `vite build --mode ` per environment.
+- You're OK with one build per environment, selected via `--mode ` or a [`VITE_ENV` env var](/docs/guides/custom-modes#selecting-the-env-without---mode).
If a single multi-env build is acceptable, the simpler [dynamic-import pattern](/docs/guides/spa-dynamic-import) is the default.
@@ -76,11 +76,11 @@ vite build --mode production
vite build --mode staging
```
-Each command produces a separate `dist/` whose bundle contains **only** the matching `config/*.ts`.
+Each command produces a separate `dist/` whose bundle contains **only** the matching `config/*.ts`. Prefer an env var over `--mode`? `VITE_ENV=staging vite build` is equivalent — see [Selecting the env without `--mode`](/docs/guides/custom-modes#selecting-the-env-without---mode).
## What each piece does
-- **`envConfig()` plugin** registers `resolve.alias["#config"]` pointing at `[src/]config/.{ts,mts,cts,js,mjs,cjs,json}` (same discovery algorithm as `loadConfig`). It also injects `__ENV_NAME__ = JSON.stringify(mode)` so `envName()` returns the right mode in the browser — see [Custom modes](/docs/guides/custom-modes).
+- **`envConfig()` plugin** registers `resolve.alias["#config"]` pointing at `[src/]config/.{ts,mts,cts,js,mjs,cjs,json}` (same discovery algorithm as `loadConfig`). The `` is `VITE_ENV` (from `process.env` or `.env*`) falling back to Vite's `mode`. It also injects `__ENV_NAME__ = JSON.stringify(env)` so `envName()` returns the right env in the browser — see [Custom modes](/docs/guides/custom-modes).
- **`#config.d.ts`** declares the type of the alias for TypeScript. Vite resolves the runtime import; this just stops `tsc` from complaining.
- **`defineEnv({ schema, config })`** runs synchronously here — `config` is a plain object, not a Promise.
@@ -90,12 +90,13 @@ Each command produces a separate `dist/` whose bundle contains **only** the matc
envConfig({
alias: "#config", // default
cwd: process.cwd(), // default — base directory for discovery
+ envVar: "VITE_ENV", // default — env var that selects the env (falls back to `mode`)
});
```
-When no file matches the current mode, the alias resolves to a virtual module that throws **only if `#config` is actually imported** — so tools that introspect the Vite config (Vitest's IDE-driven discovery, third-party plugins) don't trip over a config-time error.
+When no file matches the current env, the alias resolves to a virtual module that throws **only if `#config` is actually imported** — so tools that introspect the Vite config (Vitest's IDE-driven discovery, third-party plugins) don't trip over a config-time error.
## Tradeoffs
-- **One build per env.** Your CI/CD needs to run `vite build --mode ` for every environment you ship, and produce a separate artifact for each.
+- **One build per env.** Your CI/CD needs to run a build (`vite build --mode `, or `VITE_ENV= vite build`) for every environment you ship, and produce a separate artifact for each.
- **No runtime switching.** Once built, the artifact is locked to its env. If you need to swap envs without rebuilding, use the [dynamic-import pattern](/docs/guides/spa-dynamic-import).
diff --git a/docsite/src/components/landing/data.ts b/docsite/src/components/landing/data.ts
index d9a3d84..0c0278f 100644
--- a/docsite/src/components/landing/data.ts
+++ b/docsite/src/components/landing/data.ts
@@ -116,7 +116,7 @@ export const WHY_POINTS = [
] as const;
export const LANDING_META = {
- version: "v0.4.0",
+ version: "v0.5.0",
vlandUrl: "https://variable.land",
githubUrl: "https://github.com/variableland/env",
npmUrl: "https://www.npmjs.com/package/@vlandoss/env",
diff --git a/examples/spa-vite-plugin/test/e2e/build.spec.ts b/examples/spa-vite-plugin/test/e2e/build.spec.ts
index c36f5d5..87e0cdc 100644
--- a/examples/spa-vite-plugin/test/e2e/build.spec.ts
+++ b/examples/spa-vite-plugin/test/e2e/build.spec.ts
@@ -7,11 +7,12 @@ import { expect, test } from "@playwright/test";
const ROOT = fileURLToPath(new URL("../..", import.meta.url));
const DIST = path.join(ROOT, "dist");
-function runViteBuild(mode: string) {
+function runViteBuild(mode: string, env?: Record) {
return spawnSync("pnpm", ["exec", "vite", "build", "--mode", mode], {
cwd: ROOT,
encoding: "utf8",
timeout: 60_000,
+ env: { ...process.env, ...env },
});
}
@@ -46,9 +47,20 @@ test.describe("per-mode bundle isolation (envConfig plugin)", () => {
expect(bundle).not.toContain("prod-build-marker-b71c");
});
- test("build fails when no config file matches the mode", () => {
+ test("VITE_ENV selects the env, overriding --mode", () => {
+ // --mode development would normally embed the development config, but
+ // VITE_ENV=production wins — proving the plugin keys off the env var.
+ const result = runViteBuild("development", { VITE_ENV: "production" });
+ expect(result.status, result.stderr).toBe(0);
+
+ const bundle = readAllJs();
+ expect(bundle).toContain("prod-build-marker-b71c");
+ expect(bundle).not.toContain("dev-build-marker-9f3a");
+ });
+
+ test("build fails when no config file matches the env", () => {
const result = runViteBuild("staging");
expect(result.status).not.toBe(0);
- expect(result.stderr + result.stdout).toMatch(/no config file found for mode "staging"/);
+ expect(result.stderr + result.stdout).toMatch(/no config file found for env "staging"/);
});
});
diff --git a/package/src/__tests__/vite.test.ts b/package/src/__tests__/vite.test.ts
index 7849ec4..07a67e7 100644
--- a/package/src/__tests__/vite.test.ts
+++ b/package/src/__tests__/vite.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it } from "vitest";
import { envConfig } from "../vite.ts";
const fixturesDir = new URL("./fixtures", import.meta.url).pathname;
@@ -67,7 +67,7 @@ describe("envConfig() Vite plugin", () => {
const plugin = envConfig({ cwd: fixturesDir });
const result = invoke(plugin, "missing");
const virtualId = result.resolve.alias["#config"];
- expect(() => invokeLoad(plugin, virtualId)).toThrow(/no config file found for mode "missing"/);
+ expect(() => invokeLoad(plugin, virtualId)).toThrow(/no config file found for env "missing"/);
});
it("still injects __ENV_NAME__ when no config matches the mode", () => {
@@ -102,4 +102,42 @@ describe("envConfig() Vite plugin", () => {
const result = invoke(plugin, "staging");
expect(result.define.__ENV_NAME__).toBe('"staging"');
});
+
+ describe("VITE_ENV resolution", () => {
+ afterEach(() => {
+ delete process.env.VITE_ENV;
+ delete process.env.APP_ENV;
+ });
+
+ it("prefers VITE_ENV over Vite's mode for discovery and __ENV_NAME__", () => {
+ process.env.VITE_ENV = "staging";
+ const plugin = envConfig({ cwd: fixturesDir });
+ // `--mode development` would resolve config/development.json, but VITE_ENV wins.
+ const result = invoke(plugin, "development");
+ expect(result.resolve.alias["#config"]).toMatch(/fixtures\/src\/config\/staging\.json$/);
+ expect(result.define.__ENV_NAME__).toBe('"staging"');
+ });
+
+ it("falls back to mode when VITE_ENV is unset", () => {
+ const plugin = envConfig({ cwd: fixturesDir });
+ const result = invoke(plugin, "development");
+ expect(result.resolve.alias["#config"]).toMatch(/fixtures\/config\/development\.json$/);
+ expect(result.define.__ENV_NAME__).toBe('"development"');
+ });
+
+ it("treats an empty VITE_ENV as unset and falls back to mode", () => {
+ process.env.VITE_ENV = "";
+ const plugin = envConfig({ cwd: fixturesDir });
+ const result = invoke(plugin, "production");
+ expect(result.define.__ENV_NAME__).toBe('"production"');
+ });
+
+ it("honors a custom envVar name", () => {
+ process.env.APP_ENV = "staging";
+ const plugin = envConfig({ cwd: fixturesDir, envVar: "APP_ENV" });
+ const result = invoke(plugin, "development");
+ expect(result.resolve.alias["#config"]).toMatch(/fixtures\/src\/config\/staging\.json$/);
+ expect(result.define.__ENV_NAME__).toBe('"staging"');
+ });
+ });
});
diff --git a/package/src/vite.ts b/package/src/vite.ts
index 34ceb7f..a777d4b 100644
--- a/package/src/vite.ts
+++ b/package/src/vite.ts
@@ -1,23 +1,26 @@
import { statSync } from "node:fs";
import path from "node:path";
-import type { Plugin } from "vite";
+import { loadEnv, type Plugin } from "vite";
import { BUILD_TIME_ENV_NAME_ID } from "./lib/const.ts";
const EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"];
const DIRS = ["config", "src/config"];
+/** Env var the resolved env name is read from before falling back to Vite's `mode`. */
+const DEFAULT_ENV_VAR = "VITE_ENV";
+
/**
* Rollup virtual-module id (the leading `\0` prevents other plugins from
* trying to read it from disk) used as a placeholder when no config file
- * matches the current mode. The error is deferred until something actually
+ * matches the current env. The error is deferred until something actually
* imports `#config` — see `load()` below.
*/
const VIRTUAL_MISSING_ID = "\0variableland-env-config:missing";
-function findConfigFile(mode: string, cwd: string): string | undefined {
+function findConfigFile(env: string, cwd: string): string | undefined {
for (const ext of EXTENSIONS) {
for (const dir of DIRS) {
- const candidate = path.join(cwd, dir, `${mode}${ext}`);
+ const candidate = path.join(cwd, dir, `${env}${ext}`);
try {
if (statSync(candidate).isFile()) return candidate;
} catch {
@@ -28,26 +31,46 @@ function findConfigFile(mode: string, cwd: string): string | undefined {
return undefined;
}
+/**
+ * Resolve the env name the plugin keys off. Reads `envVar` (default `VITE_ENV`)
+ * from `process.env` **and** the `.env*` files under `cwd` — Vite's `loadEnv`
+ * merges both, with inline/shell values taking precedence — and falls back to
+ * Vite's `mode` when the var is unset or empty. Passing the full var name as
+ * the prefix scopes the file scan to just that one key.
+ */
+function resolveEnvName(envVar: string, mode: string, cwd: string): string {
+ return loadEnv(mode, cwd, envVar)[envVar] || mode;
+}
+
export type EnvConfigOptions = {
- /** Alias the per-mode config file is exposed as. Default: `"#config"`. */
+ /** Alias the per-env config file is exposed as. Default: `"#config"`. */
alias?: string;
/** Base directory for the discovery search. Default: `process.cwd()`. */
cwd?: string;
+ /**
+ * Env var that selects the env name, read from `process.env` and `.env*`
+ * files. When unset (or empty) the plugin falls back to Vite's `mode`, so
+ * `--mode` keeps working unchanged. Default: `"VITE_ENV"`.
+ */
+ envVar?: string;
};
/**
* Vite plugin that:
*
- * 1. Resolves an alias (`#config` by default) to the config file matching
- * Vite's `mode`. Discovery is `[src/]config/.{ts,mts,cts,js,mjs,cjs,json}` —
- * same algorithm as `loadConfig` in `@vlandoss/env/fs`. Only the
- * matched file enters the bundle.
- * 2. Injects `define: { __ENV_NAME__: JSON.stringify(mode) }`. The core's
+ * 1. Resolves an alias (`#config` by default) to the config file matching the
+ * current env name. The env name comes from `VITE_ENV` (configurable via
+ * `envVar`), falling back to Vite's `mode` — so `VITE_ENV=staging vite build`
+ * and `vite build --mode staging` are equivalent, and you no longer have to
+ * thread `--mode` through every command. Discovery is
+ * `[src/]config/.{ts,mts,cts,js,mjs,cjs,json}` — same algorithm as
+ * `loadConfig` in `@vlandoss/env/fs`. Only the matched file enters the bundle.
+ * 2. Injects `define: { __ENV_NAME__: JSON.stringify(env) }`. The core's
* `envName()` reads this identifier so dynamic-import and alias patterns
- * alike return the correct env in the browser — including custom modes
+ * alike return the correct env in the browser — including custom envs
* like `staging` or `qa`, which Vite forces `NODE_ENV="production"` for.
*
- * When no config file matches the current mode, the plugin still registers
+ * When no config file matches the current env, the plugin still registers
* everything correctly (the `__ENV_NAME__` inject keeps working for the
* dynamic-import pattern that doesn't use `#config`). The alias resolves to
* a virtual module that throws a descriptive error **only when imported** —
@@ -62,6 +85,9 @@ export type EnvConfigOptions = {
*
* export default defineConfig({ plugins: [envConfig()] });
*
+ * // Pick the env without --mode:
+ * // VITE_ENV=staging vite build
+ *
* // src/env/index.ts
* import config from "#config";
* import { defineEnv } from "@vlandoss/env";
@@ -72,13 +98,14 @@ export type EnvConfigOptions = {
export function envConfig(options: EnvConfigOptions = {}): Plugin {
const alias = options.alias ?? "#config";
const cwd = options.cwd ?? process.cwd();
- let resolvedMode = "";
+ const envVar = options.envVar ?? DEFAULT_ENV_VAR;
+ let resolvedEnv = "";
return {
name: "variableland-env-config",
config(_userConfig, { mode }) {
- resolvedMode = mode;
- const file = findConfigFile(mode, cwd);
+ resolvedEnv = resolveEnvName(envVar, mode, cwd);
+ const file = findConfigFile(resolvedEnv, cwd);
return {
resolve: {
alias: {
@@ -86,7 +113,7 @@ export function envConfig(options: EnvConfigOptions = {}): Plugin {
},
},
define: {
- [BUILD_TIME_ENV_NAME_ID]: JSON.stringify(mode),
+ [BUILD_TIME_ENV_NAME_ID]: JSON.stringify(resolvedEnv),
},
};
},
@@ -97,7 +124,7 @@ export function envConfig(options: EnvConfigOptions = {}): Plugin {
load(id) {
if (id === VIRTUAL_MISSING_ID) {
throw new Error(
- `@vlandoss/env/vite: no config file found for mode "${resolvedMode}" — searched [src/]config/${resolvedMode}.{ts,mts,cts,js,mjs,cjs,json} under ${cwd}`,
+ `@vlandoss/env/vite: no config file found for env "${resolvedEnv}" — searched [src/]config/${resolvedEnv}.{ts,mts,cts,js,mjs,cjs,json} under ${cwd}`,
);
}
return undefined;