Skip to content
Open
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
6 changes: 6 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM debian:bookworm

RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /workspaces/varlock
28 changes: 28 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "Varlock DevContainer",
"dockerFile": "Dockerfile",
"customizations": {
"vscode": {
"extensions": [
"ms-azuretools.vscode-containers",
"ms-azuretools.vscode-docker",
"varlock.env-spec-language",
"dbaeumer.vscode-eslint",
"vercel.turbo-vsc"
],
"settings": {
"editor.tabSize": 2,
"terminal.integrated.defaultProfile.linux": "zsh"
}
}
},
"features": {
"ghcr.io/stuartleeks/dev-container-features/shell-history:0": {},
"ghcr.io/schlich/devcontainer-features/powerlevel10k:1": {},
"ghcr.io/nils-geistmann/devcontainers-features/zsh:0": {},
"ghcr.io/devcontainers-extra/features/zsh-plugins:0": {},
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/devcontainers-extra/features/turborepo-npm:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
}
}
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ dist
*.ignore
ignore
.DS_Store
.pnpm-store

.turbo
vite.config.ts.timestamp*
changesets-summary.json

.env.local
.env.*.local


1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default tseslint.config(
'**/dist',
'**/dist-sea',
'**/node_modules',
'**/.pnpm-store',
'**/.turbo',
'packages/eslint-custom-rules',
'packages/env-spec-parser/src/grammar.js',
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,19 @@
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "^2.29.5",
"@eslint/js": "^9.32.0",
"@stylistic/eslint-plugin": "^5.2.2",
"@changesets/cli": "^2.29.6",
"@eslint/js": "^9.34.0",
"@stylistic/eslint-plugin": "^5.2.3",
"eslint": "^9.32.0",
"eslint-plugin-es-x": "^9.0.0",
"eslint-plugin-fix-disabled-rules": "^0.0.2",
"eslint-plugin-jsonc": "^2.20.1",
"eslint-plugin-n": "^17.21.3",
"eslint-stylistic-airbnb": "^2.0.0",
"globals": "^16.3.0",
"turbo": "^2.5.5",
"turbo": "^2.5.6",
"typescript": "catalog:",
"typescript-eslint": "^8.39.0"
"typescript-eslint": "^8.41.0"
},
"pnpm": {
"overrides": {
Expand Down
27 changes: 27 additions & 0 deletions packages/varlock-website/src/content/docs/guides/config.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
title: Project configuration (example)
description: Example of a future .varlockrc project configuration
---

:::caution
This is a forward-looking example of a potential `.varlockrc` format and is not yet part of the stable API. It illustrates how teams might centralize defaults introduced by recent CLI flags.
:::

```json title=".varlockrc"
{
"resolver": {
"respectExistingEnv": false
},
"bun": {
"dotenv": "none",
"syncNodeEnv": false
}
}
```

Notes:
- `resolver.respectExistingEnv`: when false, ambient `process.env` will not override schema-defined keys. Equivalent to the default CLI behavior; pass `--respect-existing-env` to opt in per-command.
- `bun.dotenv: none`: documents the default behavior of the Bun-aware runner, which neutralizes Bun’s dotenv by supplying an empty `--env-file`.
- `bun.syncNodeEnv`: when true, `varlock run` sets `NODE_ENV` to the resolved `@envFlag` value for Bun child processes (equivalent to `--bun-sync-node-env`).


Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,12 @@ While many have traditionally shied away from using environment-specific `.env`


### Process overrides
`varlock` will always treat environment variables passed into the process with the most precedence. This means you can rely on your external hosting platform to inject environment-specific values and still benefit from `varlock`'s validation and coercion logic.

However we recommend using injected overrides sparingly, and instead moving more config into your `.env` files.
By default, ambient `process.env` values do not override schema-defined keys. To allow ambient overrides (legacy behavior), use the CLI flag `--respect-existing-env`. We recommend using injected overrides sparingly, and instead moving more config into your `.env` files.


### Loading environment-specific `.env` files

`varlock` automatically detects all `.env.*` files in the current directory. However any environment-specific files (e.g., `.env.development`) will only be loaded if they match the value of the env var set by the [`@envFlag`](/reference/root-decorators/#envflag) root decorator.
`varlock` automatically detects all `.env.*` files in the current directory. Environment-specific files (e.g., `.env.development`) will only be loaded when they match the value of the env var set by the [`@envFlag`](/reference/root-decorators/#envflag) root decorator. `.env.local` and `.env.[env].local` are included by default and can be excluded via `--exclude-local`.

The files are applied with a specific precedence (increasing):
- `.env.schema` - your schema file, which can also contain default values
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,16 @@ Useful for debugging locally, and in CI to print out a summary of env vars.

```bash
varlock load [options]
# --exclude-local Exclude .env.local and .env.[env].local from loading
# --respect-existing-env Allow ambient process.env to override schema-defined keys
```

**Options:**
- `--format`: Format of output [pretty|json|env]
- `--show-all`: Shows all items, not just failing ones, when validation is failing
- `--env`: Set the default environment flag (e.g., `--env production`), only useful if not using `@envFlag` in `.env.schema`
- `--exclude-local`: Exclude `.env.local` and `.env.[env].local`
- `--respect-existing-env`: Allow ambient `process.env` to override schema-defined keys

**Examples:**
```bash
Expand Down Expand Up @@ -85,6 +89,9 @@ Executes a command in a child process, injecting your resolved and validated env

```bash
varlock run -- <command>
# --exclude-local Exclude .env.local and .env.[env].local from loading
# --respect-existing-env Allow ambient process.env to override schema-defined keys
# --bun-sync-node-env When running Bun, set NODE_ENV to the resolved @envFlag value
```

**Examples:**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ See [environments guide](/guides/environments) for more info
# @type=enum(development, staging, production)
APP_ENV=development
```

:::note
- `.env.local` and `.env.[env].local` are included by default and can be excluded with `--exclude-local`.
- By default, ambient `process.env` does not override schema-defined keys; use `--respect-existing-env` to allow ambient overrides.
:::
</div>

<div>
Expand Down
20 changes: 20 additions & 0 deletions packages/varlock/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ If you need to pass resolved env vars into another process, you can run:
varlock run -- node script.js
```

### New flags

```bash
varlock load [--format json] [--env production]
# --exclude-local Exclude .env.local and .env.[env].local from loading
# --respect-existing-env Allow ambient process.env to override schema-defined keys

varlock run -- <command>
# --exclude-local Exclude .env.local and .env.[env].local from loading
# --respect-existing-env Allow ambient process.env to override schema-defined keys
# --bun-sync-node-env When running Bun, set NODE_ENV to the resolved @envFlag value
```

- `.env.local` and `.env.[env].local` are included by default. Use `--exclude-local` to disable them.
- By default, ambient `process.env` does not override schema-defined keys. Use `--respect-existing-env` to allow ambient overrides.

#### Notes on using Varlock with Bun

- When running `bun`/`bunx` via `varlock run`, Varlock neutralizes Bun’s dotenv by passing an empty `--env-file` and injects the resolved environment into the child process. Optionally sync `NODE_ENV` with `--bun-sync-node-env`.

Or you can integrate more deeply with one of our [integrations](https://varlock.dev/integrations/javascript/) to get log redaction and leak prevention.

## @env-spec
Expand Down
13 changes: 11 additions & 2 deletions packages/varlock/env-graph/lib/config-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,19 @@ export class ConfigItem {
}

async process() {
// we add the final override def here so that if we process the item early (like when resolving our envFlag) it will be respected
// We add the final override def here so that if we process the item early (like when resolving our envFlag) it will be respected
// But also add it conditionally since process.env can override any item
const finalOverrideDef = this.envGraph.finalOverridesDataSource?.configItemDefs[this.key];
if (finalOverrideDef) {
this.defs.unshift({ itemDef: finalOverrideDef, source: this.envGraph.finalOverridesDataSource! });
const hasSchemaDef = this.defs.some((d) => d.source.type === 'schema');
// Special-case: always allow ambient process.env to set the envFlag value.
// This enables selecting the environment externally (e.g., APP_ENV=production)
// while still guarding other schema-defined keys unless explicitly opted in.
const isEnvFlagKey = this.envGraph.envFlagKey === this.key;
const allowProcessEnvOverride = isEnvFlagKey || this.envGraph.respectExistingEnv || !hasSchemaDef;
if (allowProcessEnvOverride) {
this.defs.unshift({ itemDef: finalOverrideDef, source: this.envGraph.finalOverridesDataSource! });
}
}

// process resolvers
Expand Down
3 changes: 3 additions & 0 deletions packages/varlock/env-graph/lib/env-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export class EnvGraph {

basePath?: string;

/** control if process.env should override schema-defined keys */
respectExistingEnv?: boolean;

/** array of data sources */
dataSources: Array<EnvGraphDataSource> = [];
finalOverridesDataSource?: EnvGraphDataSource;
Expand Down
10 changes: 10 additions & 0 deletions packages/varlock/env-graph/lib/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ export async function loadEnvGraph(opts?: {
excludeDirs?: Array<string>,
currentEnvFallback?: string,
afterInit?: (graph: EnvGraph) => Promise<void>,
excludeLocal?: boolean,
respectExistingEnv?: boolean,
}) {
const graph = new EnvGraph();
graph.basePath = opts?.basePath ?? autoDetectBasePath();
graph.respectExistingEnv = opts?.respectExistingEnv;

if (opts?.afterInit) {
await opts.afterInit(graph);
Expand All @@ -40,6 +43,13 @@ export async function loadEnvGraph(opts?: {
// must call before finishInit so the dataSource has a reference to the graph
graph.addDataSource(fileDataSource);
await fileDataSource.finishInit();

// Optionally disable .env.local and .env.<env>.local only when explicitly excluded
if (opts?.excludeLocal === true) {
if (fileDataSource.type === 'overrides' && /\.local(\.|$)/.test(fileDataSource.fileName)) {
fileDataSource.disabled = true;
}
}
}
// proocss.env overrides get some special treatment
graph.finalOverridesDataSource = new ProcessEnvDataSource();
Expand Down
10 changes: 10 additions & 0 deletions packages/varlock/src/cli/commands/load.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export const commandSpec = define({
type: 'string',
description: 'Set the environment (e.g., production, development, etc) - will be overridden by @envFlag in the schema if present',
},
'respect-existing-env': {
type: 'boolean',
description: 'Allow process.env to override schema-defined keys',
},
'exclude-local': {
type: 'boolean',
description: 'Exclude .env.local and .env.[env].local from loading',
},
},
});

Expand All @@ -34,6 +42,8 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =

const envGraph = await loadVarlockEnvGraph({
currentEnvFallback: ctx.values.env,
respectExistingEnv: Boolean(ctx.values['respect-existing-env']),
excludeLocal: ctx.values['exclude-local'] === true ? true : undefined,
});
checkForSchemaErrors(envGraph);

Expand Down
88 changes: 78 additions & 10 deletions packages/varlock/src/cli/commands/run.command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { execa, type ResultPromise } from 'execa';
import { writeFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import which from 'which';
import { define } from 'gunshi';

Expand All @@ -7,11 +10,26 @@ import { checkForConfigErrors, checkForSchemaErrors } from '../helpers/error-che
import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils';
import { gracefulExit } from 'exit-hook';


export const commandSpec = define({
name: 'run',
description: 'Run a command with your environment variables injected',
args: {
'exclude-local': {
type: 'boolean',
description: 'Exclude .env.local and .env.[env].local from loading',
},
'bun-sync-node-env': {
type: 'boolean',
description: 'When running Bun, set NODE_ENV to the resolved @envFlag value',
},
'respect-existing-env': {
type: 'boolean',
description: 'Allow process.env to override schema-defined keys',
},
env: {
type: 'string',
description: 'Set the environment (e.g., production, development, etc) - will be overridden by @envFlag in the schema if present',
},
// watch: {
// type: 'boolean',
// short: 'w',
Expand Down Expand Up @@ -47,7 +65,11 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
// console.log('running command', pathAwareCommand || rawCommand, commandArgsOnly);


const envGraph = await loadVarlockEnvGraph();
// pass through options for local files and existing env behavior
const excludeLocal = ctx.values['exclude-local'] === true ? true : undefined;
const respectExistingEnv = Boolean(ctx.values['respect-existing-env']);
const currentEnvFallback = ctx.values.env as string | undefined;
const envGraph = await loadVarlockEnvGraph({ excludeLocal, respectExistingEnv, currentEnvFallback });
checkForSchemaErrors(envGraph);
await envGraph.resolveEnvValues();
checkForConfigErrors(envGraph);
Expand All @@ -58,17 +80,63 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
// console.log(resolvedEnv);

// needs more thought here
const fullInjectedEnv = {
...process.env,
...resolvedEnv,
__VARLOCK_RUN: '1', // flag for a child process to detect it is runnign via `varlock run`
__VARLOCK_ENV: JSON.stringify(envGraph.getSerializedGraph()),
};

commandProcess = execa(pathAwareCommand || rawCommand, commandArgsOnly, {
function buildChildEnv(resolved: Record<string, any>, mode: 'whitelist' | 'all' | 'none' = 'whitelist') {
const whitelist = new Set(['PATH', 'HOME', 'SHELL', 'TERM', 'TZ', 'LANG', 'LC_ALL', 'PWD', 'TMPDIR', 'TEMP', 'TMP']);
let base: Record<string, string> = {};
if (mode === 'all') {
base = { ...process.env } as Record<string, string>;
} else if (mode === 'whitelist') {
for (const key of whitelist) {
if (process.env[key] != null) base[key] = String(process.env[key]);
}
}
// mode === 'none' → base remains empty
const merged: Record<string, string> = { ...base };
for (const k in resolved) merged[k] = resolved[k] === undefined ? '' : String(resolved[k]);
merged.__VARLOCK_RUN = '1';
merged.__VARLOCK_ENV = JSON.stringify(envGraph.getSerializedGraph());
return merged;
}

const fullInjectedEnv = buildChildEnv(resolvedEnv);

const isBun = (cmd?: string) => (cmd === 'bun' || cmd === 'bunx');
const finalCommand = pathAwareCommand || rawCommand;
let finalArgs = commandArgsOnly.slice();

let emptyEnvPath: string | undefined;
if (isBun(rawCommand)) {
// Neutralize Bun dotenv by passing an explicit empty env file
// Create a temporary empty file to ensure Bun does not auto-load dotenv
emptyEnvPath = join(tmpdir(), `.varlock-empty-${process.pid}-${Date.now()}.env`);
try {
writeFileSync(emptyEnvPath, '');
} catch (e) {
// noop
}
finalArgs = ['--env-file', emptyEnvPath, ...finalArgs];
// .env.local handling is resolved in Varlock; Bun dotenv stays disabled
if (ctx.values['bun-sync-node-env']) {
const envFlagKey = envGraph.envFlagKey;
const envFlagVal = envFlagKey ? String(resolvedEnv[envFlagKey] ?? '') : '';
if (envFlagVal) fullInjectedEnv.NODE_ENV = envFlagVal;
}
}

commandProcess = execa(finalCommand, finalArgs, {
stdio: 'inherit',
env: fullInjectedEnv,
});
// cleanup temp empty env file after process exits
commandProcess.finally(() => {
if (emptyEnvPath) {
try {
unlinkSync(emptyEnvPath);
} catch (e) {
// noop
}
}
});
// console.log('PARENT PID = ', process.pid);
// console.log('CHILD PID = ', commandProcess.pid);

Expand Down
Loading