Skip to content
Draft
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
17 changes: 17 additions & 0 deletions .changeset/nine-bugs-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"varlock": minor
---

Support multiple `loadPath` entries in `package.json` configuration.

The `varlock.loadPath` option in `package.json` now accepts an array of paths in addition to a single string. When an array is provided, all paths are loaded and their environment variables are combined. Later entries in the array take higher precedence when the same variable is defined in multiple locations.

```json title="package.json"
{
"varlock": {
"loadPath": ["./apps/my-package/envs/", "./apps/other-package/envs/"]
}
}
```

This is particularly useful in monorepos where different packages each have their own `.env` files.
14 changes: 14 additions & 0 deletions packages/varlock-website/src/content/docs/integrations/vite.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ This works for all varlock commands and integrations, not just Vite. The `loadPa
Point `loadPath` to a **directory** if you want varlock to automatically load all relevant files (`.env.schema`, `.env`, `.env.local`, etc.). Pointing to a specific file will only load that file and anything it explicitly imports via `@import`.
:::

### Loading from multiple directories

You can also provide an array of paths to combine env vars from multiple locations — useful in monorepos where different packages each have their own `.env` files:

```json title="package.json"
{
"varlock": {
"loadPath": ["./apps/my-package/envs/", "./apps/other-package/envs/"]
}
}
```

Each path in the array is loaded independently. Paths listed **later** in the array take higher precedence when the same variable is defined in multiple locations.

:::caution[Vite's `envDir` option is not supported]
If you are using Vite's `envDir` option to load `.env` files from a custom directory, note that **varlock ignores this option**. Use `varlock.loadPath` in your `package.json` instead, as shown above. Varlock will show a warning if it detects `envDir` is set.
:::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ You can configure varlock's default behavior by adding a `varlock` key to your `

| Option | Description |
|--------|-------------|
| `loadPath` | Path to a directory or specific `.env` file to use as the default entry point. Defaults to the current working directory if not set. Use a **directory path** (with trailing `/`) to automatically load all relevant files (`.env.schema`, `.env`, `.env.local`, etc.); a file path only loads that file and its explicit imports. Can be overridden by the `--path` CLI flag. Varlock looks for this config in the `package.json` in the current working directory only. |
| `loadPath` | Path (or array of paths) to a directory or specific `.env` file to use as the default entry point. Defaults to the current working directory if not set. Use a **directory path** (with trailing `/`) to automatically load all relevant files (`.env.schema`, `.env`, `.env.local`, etc.); a file path only loads that file and its explicit imports. When an array is provided, all paths are loaded and combined — later entries take higher precedence. Can be overridden by the `--path` CLI flag. Varlock looks for this config in the `package.json` in the current working directory only. |


## Commands reference
Expand Down
2 changes: 1 addition & 1 deletion packages/varlock/src/env-graph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export { loadEnvGraph } from './lib/loader';

export { EnvGraph, type SerializedEnvGraph } from './lib/env-graph';
export {
FileBasedDataSource, DotEnvFileDataSource, DirectoryDataSource,
FileBasedDataSource, DotEnvFileDataSource, DirectoryDataSource, MultiplePathsContainerDataSource,
} from './lib/data-source';
export { Resolver, StaticValueResolver } from './lib/resolver';
export { ConfigItem, type TypeGenItemInfo } from './lib/config-item';
Expand Down
56 changes: 53 additions & 3 deletions packages/varlock/src/env-graph/lib/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ export abstract class EnvGraphDataSource {
await child._processImports();
}

/**
* Marks a data source as an explicitly specified top-level entry point (e.g. specified
* via `loadPath` in package.json or `--path` CLI flag). When true, the source is not
* treated as env-specific based on its filename even if it has a parent (e.g. when wrapped
* in a MultiplePathsContainerDataSource as part of a multi-path setup).
*/
isExplicitRoot?: boolean;

/**
* Whether this data source is environment-specific.
* A source is env-specific if:
Expand All @@ -111,11 +119,12 @@ export abstract class EnvGraphDataSource {
* Note: `applyForEnv` from filename parsing is only relevant for auto-loaded files.
* Explicitly imported files (via `@import`) are controlled by the import mechanism,
* not the auto-load-by-env logic, so their `applyForEnv` is ignored here.
* Similarly, a file that is the explicit root entry point (no parent) is never
* treated as env-specific even if its filename contains an env qualifier.
* Similarly, a file that is the explicit root entry point (no parent, or marked with
* `isExplicitRoot`) is never treated as env-specific even if its filename contains
* an env qualifier.
*/
get isEnvSpecific(): boolean {
if (this.applyForEnv && !this.isImport && this.parent) return true;
if (this.applyForEnv && !this.isImport && this.parent && !this.isExplicitRoot) return true;
if (this.type === 'overrides') return true;
if (this._hasConditionalDisable) return true;
if (this.importMeta?.isConditionallyEnabled) return true;
Expand Down Expand Up @@ -902,3 +911,44 @@ export class DirectoryDataSource extends EnvGraphDataSource {
}
}
}

/**
* A virtual root container that holds multiple top-level data sources.
* Used when `loadPath` in package.json is set to an array of paths.
* Each path is loaded as an independent DirectoryDataSource or DotEnvFileDataSource,
* combined in the order they are specified (later paths have higher precedence).
*/
export class MultiplePathsContainerDataSource extends EnvGraphDataSource {
type = 'container' as const;
typeLabel = 'multi-path-container';
get label() { return `multi-path container (${this.paths.length} paths)`; }

constructor(
/** Pre-resolved absolute paths (directories ending with path.sep, or file paths) */
readonly paths: Array<string>,
) {
super();
}

async _finishInit() {
if (!this.graph) throw new Error('expected graph to be set');

for (const entryPath of this.paths) {
const stat = await fs.stat(entryPath).catch(() => undefined);
const isDirectory = entryPath.endsWith('/') || entryPath.endsWith(path.sep)
|| (stat?.isDirectory() ?? false);

const child: EnvGraphDataSource = isDirectory
? new DirectoryDataSource(entryPath)
: new DotEnvFileDataSource(entryPath);

// Mark as an explicit root entry point so `isEnvSpecific` behaves as if it had no parent
child.isExplicitRoot = true;

await this._initChild(child);
// For DirectoryDataSource, imports are handled internally in its own _finishInit.
// For DotEnvFileDataSource, we must process imports explicitly here.
await child._processImports();
}
}
}
12 changes: 10 additions & 2 deletions packages/varlock/src/env-graph/lib/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import fs from 'node:fs';
import path from 'node:path';
import _ from '@env-spec/utils/my-dash';
import { EnvGraph } from './env-graph';
import { DirectoryDataSource, DotEnvFileDataSource } from './data-source';
import { DirectoryDataSource, DotEnvFileDataSource, MultiplePathsContainerDataSource } from './data-source';

export async function loadEnvGraph(opts?: {
basePath?: string,
entryFilePath?: string,
/** Multiple entry file paths — when provided, creates a virtual root container for all paths */
entryFilePaths?: Array<string>,
relativePaths?: Array<string>,
checkGitIgnored?: boolean,
excludeDirs?: Array<string>,
Expand All @@ -15,7 +17,13 @@ export async function loadEnvGraph(opts?: {
}) {
const graph = new EnvGraph();

if (opts?.entryFilePath) {
if (opts?.entryFilePaths && opts.entryFilePaths.length > 0) {
graph.basePath = opts?.basePath ?? process.cwd();
if (opts?.afterInit) await opts.afterInit(graph);
if (opts?.currentEnvFallback) graph.envFlagFallback = opts.currentEnvFallback;
const resolvedPaths = opts.entryFilePaths.map((p) => path.resolve(p));
await graph.setRootDataSource(new MultiplePathsContainerDataSource(resolvedPaths));
} else if (opts?.entryFilePath) {
const resolvedPath = path.resolve(opts.entryFilePath);
const isDirectory = opts.entryFilePath.endsWith('/') || opts.entryFilePath.endsWith(path.sep)
|| (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory());
Expand Down
171 changes: 171 additions & 0 deletions packages/varlock/src/env-graph/test/multi-path-loading.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
describe, test, expect,
} from 'vitest';
import path from 'node:path';
import outdent from 'outdent';
import { EnvGraph, MultiplePathsContainerDataSource } from '../index';

/**
* Helper to create a graph with multiple virtual directories.
* Each key in the `dirs` map is a directory path (should end with path.sep),
* and its value is a Record of filename → content.
*/
async function multiDirTest(spec: {
dirs: Record<string, Record<string, string>>;
overrideValues?: Record<string, string>;
fallbackEnv?: string;
}) {
const g = new EnvGraph();

g.virtualImports = {};
for (const [dirPath, files] of Object.entries(spec.dirs)) {
for (const [fileName, content] of Object.entries(files)) {
g.virtualImports[path.join(dirPath, fileName)] = content;
}
}

if (spec.overrideValues) g.overrideValues = spec.overrideValues;
if (spec.fallbackEnv) g.envFlagFallback = spec.fallbackEnv;

// Ensure paths end with sep so MultiplePathsContainerDataSource treats them as directories
const paths = Object.keys(spec.dirs).map((p) => (p.endsWith(path.sep) ? p : p + path.sep));
await g.setRootDataSource(new MultiplePathsContainerDataSource(paths));
await g.finishLoad();

return g;
}

describe('MultiplePathsContainerDataSource', () => {
test('loads items from two separate directories', async () => {
const g = await multiDirTest({
dirs: {
'/tmp/varlock-test/dir1/': {
'.env.schema': outdent`
ITEM1=from-dir1
`,
},
'/tmp/varlock-test/dir2/': {
'.env.schema': outdent`
ITEM2=from-dir2
`,
},
},
});

await g.resolveEnvValues();

expect(g.configSchema.ITEM1?.resolvedValue).toBe('from-dir1');
expect(g.configSchema.ITEM2?.resolvedValue).toBe('from-dir2');
expect(Object.keys(g.configSchema)).not.toContain('ITEM3');
});

test('later path has higher precedence than earlier path', async () => {
const g = await multiDirTest({
dirs: {
'/tmp/varlock-test/dir1/': {
'.env.schema': outdent`
SHARED_ITEM=from-dir1
`,
},
'/tmp/varlock-test/dir2/': {
'.env.schema': outdent`
SHARED_ITEM=from-dir2
`,
},
},
});

await g.resolveEnvValues();

// dir2 (last path) has higher precedence
expect(g.configSchema.SHARED_ITEM?.resolvedValue).toBe('from-dir2');
});

test('env-specific files are loaded per directory', async () => {
const g = await multiDirTest({
dirs: {
'/tmp/varlock-test/dir1/': {
'.env.schema': outdent`
# @currentEnv=$APP_ENV
# ---
APP_ENV=dev
ITEM1=from-dir1-schema
`,
'.env.production': outdent`
ITEM1=from-dir1-prod
`,
},
'/tmp/varlock-test/dir2/': {
'.env.schema': outdent`
ITEM2=from-dir2-schema
`,
},
},
overrideValues: { APP_ENV: 'production' },
});

await g.resolveEnvValues();

expect(g.configSchema.ITEM1?.resolvedValue).toBe('from-dir1-prod');
expect(g.configSchema.ITEM2?.resolvedValue).toBe('from-dir2-schema');
});

test('direct children of container are not treated as env-specific', async () => {
const g = await multiDirTest({
dirs: {
'/tmp/varlock-test/dir1/': {
'.env.schema': outdent`
ITEM1=from-dir1
`,
},
'/tmp/varlock-test/dir2/': {
'.env.schema': outdent`
ITEM2=from-dir2
`,
},
},
});

// The root DirectoryDataSource children should NOT be env-specific
const rootChildren = g.rootDataSource?.children ?? [];
for (const child of rootChildren) {
expect(child.isEnvSpecific).toBe(false);
}
});

test('single path in array behaves like regular directory loading', async () => {
const g = await multiDirTest({
dirs: {
'/tmp/varlock-test/dir1/': {
'.env.schema': outdent`
ITEM1=from-dir1
`,
'.env': outdent`
ITEM1=from-dir1-env
`,
},
},
});

await g.resolveEnvValues();

// .env overrides .env.schema
expect(g.configSchema.ITEM1?.resolvedValue).toBe('from-dir1-env');
});

test('no loading errors when all directories exist (virtual)', async () => {
const g = await multiDirTest({
dirs: {
'/tmp/varlock-test/dir1/': {
'.env.schema': 'ITEM1=val1',
},
'/tmp/varlock-test/dir2/': {
'.env.schema': 'ITEM2=val2',
},
},
});

const sourcesWithLoadingErrors = g.sortedDataSources.filter((s) => s.loadingError);
expect(sourcesWithLoadingErrors).toHaveLength(0);
});
});
Loading
Loading