Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .bumpy/unify-undefined-injection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: minor
---

Behavior change: schema items that resolve to undefined are no longer injected into process.env as empty strings by auto-load, matching `varlock run` and the documented `VAR=` semantics (so `process.env.MY_VAR ?? 'fallback'` works). `varlock load --format shell` now also skips them. If your code relies on unset vars being `""`, add `# @injectUndefinedAsEmpty` to your `.env.schema` header to restore the old behavior; when set, generated types mark process.env keys as always-present strings (optional enums become `"a" | "b" | ""`).
18 changes: 18 additions & 0 deletions framework-tests/frameworks/vite/files/pages/ssr-undefined-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
Executed with plain `node dist/ssr-undefined-entry.js` after an SSR build with
ssrInjectMode=resolved-env, so the inlined varlock init runs and performs the
process.env injection according to the blob's settings. Logs the three env
surfaces so tests can assert how an unset schema item (UNSET_VAR=) appears on each.
*/
import { ENV } from 'varlock/env';

console.log(`unset-in-process-env::${'UNSET_VAR' in process.env}`);
console.log(`process-env-unset::${JSON.stringify(process.env.UNSET_VAR)}`);
console.log(`process-env-set::${process.env.PUBLIC_VAR}`);
// vite only exposes prefixed keys (plus its builtins) through import.meta.env,
// so an unprefixed schema key is absent here regardless of injection mode
console.log(`import-meta-env-unset::${JSON.stringify(import.meta.env.UNSET_VAR)}`);
// static (non-sensitive, non-dynamic) items are inlined at build time; an unset
// item inlines as the `undefined` literal in both modes
console.log(`env-proxy-unset::${String(ENV.UNSET_VAR)}`);
console.log('ssr-undefined-check-done');
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# @defaultSensitive=false @defaultRequired=infer
# @currentEnv=$APP_ENV
# ---

# @type=enum(dev, prod)
APP_ENV=dev

PUBLIC_VAR=public-test-value
API_URL=https://api.example.com
ENV_SPECIFIC_VAR=env-specific-default

UNSET_VAR=

# @sensitive
SECRET_KEY=super-secret-value
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# @defaultSensitive=false @defaultRequired=infer
# @currentEnv=$APP_ENV
# @injectUndefinedAsEmpty
# ---

# @type=enum(dev, prod)
APP_ENV=dev

PUBLIC_VAR=public-test-value
API_URL=https://api.example.com
ENV_SPECIFIC_VAR=env-specific-default

UNSET_VAR=

# @sensitive
SECRET_KEY=super-secret-value
60 changes: 59 additions & 1 deletion framework-tests/frameworks/vite/vite-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ Shared Vite test definitions, parameterized by Vite version.
Covers static builds, HTML constant replacement, leak detection,
log redaction, sourcemap scrubbing, SSR init injection, and dev server.
*/
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
describe, beforeAll, afterAll,
describe, beforeAll, afterAll, test, expect,
} from 'vitest';
import { FrameworkTestEnv } from '../../harness/index';

Expand Down Expand Up @@ -331,6 +332,63 @@ export function defineViteTests(
});
});

// ---- Undefined injection modes ----

// Build an SSR entry with the resolved env inlined, then execute it with plain node so
// the inlined varlock init performs the process.env injection per the blob's settings.
// This verifies how an unset schema item (`UNSET_VAR=`) appears on each env surface.
describe('undefined injection modes (SSR runtime)', () => {
async function buildAndRunSsrEntry(schemaTemplate: string) {
const buildResult = await viteEnv.runScenario({
command: 'vite build --ssr src/ssr-undefined-entry.ts',
templateFiles: {
'vite.config.ts': 'vite-configs/vite.config.resolved-env.ts',
'index.html': 'html/basic.html',
'.env.schema': schemaTemplate,
'src/ssr-undefined-entry.ts': 'pages/ssr-undefined-entry.ts',
},
});
expect(buildResult.exitCode).toBe(0);

// scrub anything that could interfere with the standalone run: the bundle must
// hydrate from its inlined blob, and ambient values must not mask the scenario
const cleanEnv = { ...process.env };
for (const key of ['__VARLOCK_ENV', '_VARLOCK_ENV_KEY', 'UNSET_VAR', 'PUBLIC_VAR']) {
delete cleanEnv[key];
}
const runResult = spawnSync('node', ['dist/ssr-undefined-entry.js'], {
cwd: viteEnv.dir,
encoding: 'utf-8',
timeout: 30_000,
env: cleanEnv,
});
const output = (runResult.stdout ?? '') + (runResult.stderr ?? '');
expect(output).toContain('ssr-undefined-check-done');
return output;
}

test('default: unset items are left out of process.env', async () => {
const output = await buildAndRunSsrEntry('schemas/.env.schema.undefined-injection');
expect(output).toContain('unset-in-process-env::false');
expect(output).toContain('process-env-unset::undefined');
expect(output).toContain('process-env-set::public-test-value');
expect(output).toContain('import-meta-env-unset::undefined');
expect(output).toContain('env-proxy-unset::undefined');
}, 180_000);

test('@injectUndefinedAsEmpty: empty strings land on process.env but not import.meta.env', async () => {
const output = await buildAndRunSsrEntry('schemas/.env.schema.undefined-injection-empty');
expect(output).toContain('unset-in-process-env::true');
expect(output).toContain('process-env-unset::""');
expect(output).toContain('process-env-set::public-test-value');
// import.meta.env only carries framework-prefixed keys, so the unset schema
// item stays undefined there even in empty-injection mode
expect(output).toContain('import-meta-env-unset::undefined');
// the ENV surface always reflects the real resolved value
expect(output).toContain('env-proxy-unset::undefined');
}, 180_000);
});

// ---- Dev server ----

describe('dev server', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ Finally, you can remove `dotenv` from your dependencies:

<InstallJsDepsWidget packages="dotenv" remove />

:::note[Unset items are not injected as empty strings]
With varlock, a schema item with no value set (`MY_VAR=`) resolves to undefined and is left out of `process.env` entirely, so `process.env.MY_VAR ?? 'fallback'` works as expected. Some dotenv-style setups instead end up with empty strings for unset vars. If your code relies on that, set [`@injectUndefinedAsEmpty`](/reference/root-decorators/#injectundefinedasempty) in your `.env.schema`.
:::

## Using overrides

If `dotenv` is being used under the hood of one of your dependencies, you can use `overrides` to swap in `varlock` instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Values may be static, or set using [functions](/reference/functions/), which can
- Multiline values can be wrapped in <code>```</code>, `"""`. Also supported is `"` and `'` but not recommended.
- Unquoted values will be parsed as a number/boolean/undefined where possible (`ITEM=foo` -> `"foo"`, while `ITEM=true` -> `true`), however data-types may further coerce values
- No value (undefined) and empty string ("") are distinct
- this holds through injection too: an item that resolves to undefined is left out of `process.env` entirely (so `process.env.MY_VAR ?? 'fallback'` works), while an explicit `""` is injected as an empty string. See [`@injectUndefinedAsEmpty`](/reference/root-decorators/#injectundefinedasempty) if you want dotenv-style empty-string injection instead.

```env-spec title=".env.schema"
NO_VALUE= # will resolve to undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ varlock load --filter="#billing"
- `--format json`: a flat `{ "KEY": value }` map of resolved values on stdout
- `--agent`: the same flat map, but `@sensitive` values are redacted (e.g. `"su▒▒▒▒▒"`), so it is safe to print in logs or agent transcripts. Implies JSON output; not compatible with `--format env`/`shell`. Combine with `--agent --format json-full` to get the redacted full graph.
- `--format json-full`: the full serialized graph: top-level `basePath`, `sources`, `config` (per-item metadata including resolved value, validation state, and sensitivity), `settings`. Use this when you need per-item validation/error detail rather than just values. ⚠️ This includes **raw resolved secret values**, so add `--agent` (`--agent --format json-full`) to redact them before logging or feeding to an agent. [`@internal`](/reference/item-decorators/#internal) items are excluded unless you pass `--include-internal`.
- `--format env` / `--format shell`: dotenv lines / shell `export` statements with **raw** values. Never pipe these somewhere that gets logged when secrets are involved.
- `--format env` / `--format shell`: dotenv lines / shell `export` statements with **raw** values. Never pipe these somewhere that gets logged when secrets are involved. Items that resolve to undefined are emitted as `KEY=` lines in env format (which round-trip to undefined when re-read by varlock), but are skipped in shell format, since `export KEY=` would set an empty string instead. Set [`@injectUndefinedAsEmpty`](/reference/root-decorators/#injectundefinedasempty) to get empty-string exports for them.

When emitting machine-readable output, add `--summary-stderr` (or `--summary-file`) to get a human-readable, redacted summary on **stderr** while keeping clean JSON on **stdout**. This is handy for agents and CI.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,35 @@ This decorator only affects the runtime `initVarlockEnv()` behavior. It does **n
:::
</div>

<div>
### `@injectUndefinedAsEmpty`
**Value type:** `boolean`

Controls how items that resolve to `undefined` (for example an optional `MY_VAR=` with no value set) are injected into `process.env`.

By default they are left out of `process.env` entirely, so `process.env.MY_VAR === undefined` and patterns like `process.env.MY_VAR ?? 'fallback'` work as expected. This matches the documented value semantics: no value is `undefined`, while an explicit `MY_VAR=""` is an empty string, and the two stay distinct all the way through injection.

Most other .env loaders instead set unset vars to an empty string. If you have code that relies on that (for example truthiness checks that expect `""`, or `'MY_VAR' in process.env`), set this decorator to restore that behavior.

**Options:**
- `false` (default): Items that resolve to `undefined` are not injected. This applies to auto-load / `initVarlockEnv()`, `varlock run`, and `varlock load --format shell`.
- `true`: Items that resolve to `undefined` are injected as empty strings (`""`), matching dotenv-style loaders.

The value must be static (`true`/`false`). Because code generation reads this flag, an environment-dependent value would make generated output differ per environment.

```env-spec
# @injectUndefinedAsEmpty
# ---
OPTIONAL_VAR= # @optional
```

Generated TypeScript types reflect this: when enabled, the `process.env` augmentation from [`@generateTsTypes`](#generatetstypes) marks every schema key as always present, since unset items are injected as `""`. An optional string types as `string` instead of `string | undefined`, and literal-typed items keep their unions with `""` added (an optional `enum(alpha, beta)` becomes `"alpha" | "beta" | ""`). The `import.meta.env` augmentation keeps its optional keys: frameworks like Vite and Astro only expose prefixed keys through `import.meta.env`, so a schema key can be absent there regardless of this setting.

:::note
The `ENV` proxy is unaffected: `ENV.OPTIONAL_VAR` always returns the real resolved value (`undefined` when unset), regardless of this setting, and its generated types keep optional keys optional. The same goes for the other language generators (`@generatePythonEnv`, `@generateGoEnv`, etc.): their loaders parse the `__VARLOCK_ENV` blob into coerced values, so unset items stay absent/`None`/`Option::None` rather than becoming empty strings.
:::
</div>

<div>
### `@auditIgnorePaths()`
**Arg types:** `[ ...paths: string[] ]`
Expand Down
6 changes: 5 additions & 1 deletion packages/varlock/src/cli/commands/load.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
// in their process.env string form (composites become separator-joined/JSON strings);
// the typed object above still decides quoting (bare numbers/booleans stay unquoted)
const resolvedEnvStrings = envGraph.getResolvedEnvStringObject({ filterKeys });
const skipUndefined = compact === true;
// shell format: `export KEY=` would set an empty string in the shell, misrepresenting an
// unset item — skip undefined items unless `@injectUndefinedAsEmpty` opts into that.
// env format keeps its `KEY=` lines, which round-trip to undefined in the varlock dialect.
const skipUndefined = compact === true
|| (outputFormat === 'shell' && !envGraph.injectUndefinedAsEmpty);
const prefix = outputFormat === 'shell' ? 'export ' : '';

for (const key in resolvedEnv) {
Expand Down
11 changes: 10 additions & 1 deletion packages/varlock/src/cli/commands/run.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
// @internal items, so there is nothing extra to strip.
resolvedEnv = {};
for (const [itemKey, item] of Object.entries(serializedGraph.config)) {
resolvedEnv[itemKey] = item.value === undefined ? undefined : injectedEnvStringForm(item);
resolvedEnv[itemKey] = injectedEnvStringForm(item);
}
} else {
debug('resolving env (%s)', reuseDecision.reason);
Expand Down Expand Up @@ -203,6 +203,15 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
resolvedEnv = envGraph.getResolvedEnvStringObject({ includeInternal, filterKeys });
serializedGraph = envGraph.getSerializedGraph({ filterKeys });
}

// `@injectUndefinedAsEmpty` opts into dotenv-style behavior: unset items become empty strings
// in the child env instead of being dropped (either way they mask any inherited value)
if (serializedGraph.settings?.injectUndefinedAsEmpty) {
for (const itemKey in resolvedEnv) {
if (resolvedEnv[itemKey] === undefined) resolvedEnv[itemKey] = '';
}
}

const { resetRedactionMap } = await import('../../runtime/env');
// console.log(resolvedEnv);

Expand Down
13 changes: 13 additions & 0 deletions packages/varlock/src/env-graph/lib/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,19 @@ export const builtInRootDecorators: Array<RootDecoratorDef<any>> = [
}
},
},
{
// opt-in dotenv-style compatibility: items that resolve to undefined get injected into
// process.env (and shell exports) as empty strings instead of being left unset.
// static-only: code generation reads this flag (it controls whether process.env keys are
// typed as optional), so an env-dependent value would make generated output differ per
// active environment
name: 'injectUndefinedAsEmpty',
process: (decVal) => {
if (!decVal.isStatic || !_.isBoolean(decVal.staticValue)) {
throw new Error('@injectUndefinedAsEmpty must be a static boolean: env-dependent values would make generated code differ per environment');
}
},
},
{
// Single-use header config for the credential proxy. The proxy itself is
// driven by @proxy decorators on items; @proxyConfig only tunes proxy-wide
Expand Down
14 changes: 14 additions & 0 deletions packages/varlock/src/env-graph/lib/env-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export type SerializedEnvGraph = {
preventLeaks?: boolean;
encryptInjectedEnv?: boolean;
disableProcessEnvInjection?: boolean;
/** true = items that resolve to undefined are injected as empty strings (dotenv compat) instead of left unset */
injectUndefinedAsEmpty?: boolean;
proxyEgress?: ProxyEgressMode;
/** `@proxyConfig={reload=...}` posture; the proxy resolves `auto` at launch. */
proxyReload?: 'off' | 'manual' | 'auto';
Expand Down Expand Up @@ -674,6 +676,7 @@ export class EnvGraph {
await this.getRootDec('preventLeaks')?.resolve();
await this.getRootDec('encryptInjectedEnv')?.resolve();
await this.getRootDec('disableProcessEnvInjection')?.resolve();
await this.getRootDec('injectUndefinedAsEmpty')?.resolve();
await this.getRootDec('proxyConfig')?.resolve();
await Promise.all(this.getRootDecFns('proxy').map(async (d) => d.resolve()));
}
Expand Down Expand Up @@ -987,6 +990,7 @@ export class EnvGraph {
serializedGraph.settings.preventLeaks = this.getRootDec('preventLeaks')?.resolvedValue ?? true;
serializedGraph.settings.encryptInjectedEnv = this.getRootDec('encryptInjectedEnv')?.resolvedValue ?? false;
serializedGraph.settings.disableProcessEnvInjection = this.getRootDec('disableProcessEnvInjection')?.resolvedValue ?? false;
serializedGraph.settings.injectUndefinedAsEmpty = this.injectUndefinedAsEmpty;
const proxyConfig = this.getRootDec('proxyConfig')?.resolvedValue;
serializedGraph.settings.proxyEgress = proxyConfig?.egress === 'strict' ? 'strict' : 'permissive';
// Store the raw reload posture (off/manual/auto); the proxy command resolves `auto`
Expand Down Expand Up @@ -1040,6 +1044,16 @@ export class EnvGraph {
return this.getRootDec('disableProcessEnvInjection')?.resolvedValue ?? false;
}

/**
* True when `@injectUndefinedAsEmpty` is set — items that resolve to undefined are injected
* into process.env (and shell exports) as empty strings, matching dotenv-style loaders.
* When false (the default), unset items are left out of process.env entirely, so
* `process.env.SOME_VAR === undefined` and `?? 'fallback'` behave as expected.
*/
get injectUndefinedAsEmpty(): boolean {
return this.getRootDec('injectUndefinedAsEmpty')?.resolvedValue ?? false;
}

/**
* Resolve every registered code-generation decorator (@generateTsTypes, @generatePythonEnv,
* plugin-contributed ones, and the deprecated @generateTypes) and write their output files.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ function generateTsFile(ctx: CodeGenContext): Promise<string> {
if (options.processEnv === undefined && ctx.graph.isProcessEnvInjectionDisabled) {
options.processEnv = 'none';
}
// `@injectUndefinedAsEmpty` means unset items land on process.env as empty strings, so the
// process.env augmentation drops its optionality (graph-level flag, not a decorator arg)
options.injectUndefinedAsEmpty = ctx.graph.injectUndefinedAsEmpty;

return generateTsTypesSrc(ctx.fields, options);
}
Expand Down
Loading
Loading