From 66f6337a4b21505930aea2962d49bde4484f40ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:28:55 +0000 Subject: [PATCH 1/5] Initial plan From 3984b1f848bf52d3a593c31a1002da32c193260f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:48:18 +0000 Subject: [PATCH 2/5] Support multiple loadPaths in package.json varlock config Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/b16894a5-e719-42be-98cf-15755b943858 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- .changeset/nine-bugs-retire.md | 17 ++ .../src/content/docs/integrations/vite.mdx | 14 ++ .../content/docs/reference/cli-commands.mdx | 2 +- packages/varlock/src/env-graph/index.ts | 2 +- .../varlock/src/env-graph/lib/data-source.ts | 55 +++++- packages/varlock/src/env-graph/lib/loader.ts | 12 +- .../env-graph/test/multi-path-loading.test.ts | 171 ++++++++++++++++++ packages/varlock/src/lib/load-graph.ts | 72 ++++++-- .../varlock/src/lib/package-json-config.ts | 4 +- 9 files changed, 322 insertions(+), 27 deletions(-) create mode 100644 .changeset/nine-bugs-retire.md create mode 100644 packages/varlock/src/env-graph/test/multi-path-loading.test.ts diff --git a/.changeset/nine-bugs-retire.md b/.changeset/nine-bugs-retire.md new file mode 100644 index 00000000..1986b48c --- /dev/null +++ b/.changeset/nine-bugs-retire.md @@ -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. diff --git a/packages/varlock-website/src/content/docs/integrations/vite.mdx b/packages/varlock-website/src/content/docs/integrations/vite.mdx index d5596a7a..8d02ec5d 100644 --- a/packages/varlock-website/src/content/docs/integrations/vite.mdx +++ b/packages/varlock-website/src/content/docs/integrations/vite.mdx @@ -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. ::: diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index e904ebc6..907176a2 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -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 diff --git a/packages/varlock/src/env-graph/index.ts b/packages/varlock/src/env-graph/index.ts index 565eee32..610d7ee6 100644 --- a/packages/varlock/src/env-graph/index.ts +++ b/packages/varlock/src/env-graph/index.ts @@ -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'; diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 1337a542..85b9f6d6 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -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: @@ -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; @@ -902,3 +911,43 @@ 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, + ) { + super(); + } + + async _finishInit() { + if (!this.graph) throw new Error('expected graph to be set'); + + for (const entryPath of this.paths) { + const isDirectory = entryPath.endsWith('/') || entryPath.endsWith(path.sep) + || (await pathExists(entryPath) && (await fs.stat(entryPath)).isDirectory()); + + 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(); + } + } +} diff --git a/packages/varlock/src/env-graph/lib/loader.ts b/packages/varlock/src/env-graph/lib/loader.ts index d847c9c5..469b565b 100644 --- a/packages/varlock/src/env-graph/lib/loader.ts +++ b/packages/varlock/src/env-graph/lib/loader.ts @@ -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, relativePaths?: Array, checkGitIgnored?: boolean, excludeDirs?: Array, @@ -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()); diff --git a/packages/varlock/src/env-graph/test/multi-path-loading.test.ts b/packages/varlock/src/env-graph/test/multi-path-loading.test.ts new file mode 100644 index 00000000..e41d9319 --- /dev/null +++ b/packages/varlock/src/env-graph/test/multi-path-loading.test.ts @@ -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>; + overrideValues?: Record; + 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 loadingErrors = g.sortedDataSources.filter((s) => s.loadingError); + expect(loadingErrors).toHaveLength(0); + }); +}); diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index 183075e0..2e728e59 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -14,35 +14,71 @@ export function loadVarlockEnvGraph(opts?: { entryFilePath?: string, }) { const pkgLoadPath = readVarlockPackageJsonConfig()?.loadPath; - const resolvedEntryFilePath = opts?.entryFilePath ?? pkgLoadPath; + // If --path flag is provided, it takes precedence over package.json config if (opts?.entryFilePath) { debug('using path from --path flag: %s', path.resolve(opts.entryFilePath)); - } else if (pkgLoadPath) { - debug('using path from package.json varlock.loadPath: %s', path.resolve(pkgLoadPath)); - } else { - debug('no path configured, using cwd: %s', process.cwd()); - } - // Validate the path early so we can give a targeted error about where it came from - if (resolvedEntryFilePath) { - const resolvedPath = path.resolve(resolvedEntryFilePath); + const resolvedPath = path.resolve(opts.entryFilePath); if (!fs.existsSync(resolvedPath)) { - if (opts?.entryFilePath) { - throw new CliExitError(`The --path value does not exist: ${resolvedPath}`, { - suggestion: 'Use `--path` to specify a valid file or directory.', - }); - } else { - throw new CliExitError(`The \`varlock.loadPath\` configured in package.json does not exist: ${resolvedPath}`, { - suggestion: 'Update `varlock.loadPath` in your package.json to point to a valid file or directory.', - }); + throw new CliExitError(`The --path value does not exist: ${resolvedPath}`, { + suggestion: 'Use `--path` to specify a valid file or directory.', + }); + } + + return runWithWorkspaceInfo(() => loadEnvGraph({ + ...opts, + entryFilePath: opts.entryFilePath, + afterInit: async (_g) => { + // TODO: register varlock resolver + }, + })); + } + + // Normalize package.json loadPath to an array (or undefined) + let pkgLoadPaths: Array | undefined; + if (pkgLoadPath) { + pkgLoadPaths = Array.isArray(pkgLoadPath) ? pkgLoadPath : [pkgLoadPath]; + } + + if (pkgLoadPaths) { + if (pkgLoadPaths.length === 1) { + debug('using path from package.json varlock.loadPath: %s', path.resolve(pkgLoadPaths[0])); + } else { + debug( + 'using %d paths from package.json varlock.loadPath: %s', + pkgLoadPaths.length, + pkgLoadPaths.map((p) => path.resolve(p)).join(', '), + ); + } + + // Validate that all paths exist + for (const p of pkgLoadPaths) { + const resolvedPath = path.resolve(p); + if (!fs.existsSync(resolvedPath)) { + throw new CliExitError( + `A path in \`varlock.loadPath\` configured in package.json does not exist: ${resolvedPath}`, + { suggestion: 'Update `varlock.loadPath` in your package.json to point to valid files or directories.' }, + ); } } + + return runWithWorkspaceInfo(() => loadEnvGraph({ + ...opts, + // For a single path, use the existing entryFilePath option for backward compatibility + entryFilePath: pkgLoadPaths.length === 1 ? pkgLoadPaths[0] : undefined, + // For multiple paths, use entryFilePaths to trigger the multi-path container + entryFilePaths: pkgLoadPaths.length > 1 ? pkgLoadPaths : undefined, + afterInit: async (_g) => { + // TODO: register varlock resolver + }, + })); } + debug('no path configured, using cwd: %s', process.cwd()); + return runWithWorkspaceInfo(() => loadEnvGraph({ ...opts, - entryFilePath: resolvedEntryFilePath, afterInit: async (_g) => { // TODO: register varlock resolver }, diff --git a/packages/varlock/src/lib/package-json-config.ts b/packages/varlock/src/lib/package-json-config.ts index edc65680..371adae5 100644 --- a/packages/varlock/src/lib/package-json-config.ts +++ b/packages/varlock/src/lib/package-json-config.ts @@ -2,8 +2,8 @@ import path from 'node:path'; import fs from 'node:fs'; export type VarlockPackageJsonConfig = { - /** Path to a specific .env file or directory to use as the entry point for loading */ - loadPath?: string; + /** Path (or array of paths) to a specific .env file or directory to use as the entry point for loading */ + loadPath?: string | Array; }; /** From 3629437e924232c612e1cef08c5577e2725c9cf0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:51:13 +0000 Subject: [PATCH 3/5] Address code review: optimize fs access, reduce redundant path.resolve, rename variable Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/b16894a5-e719-42be-98cf-15755b943858 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- .../varlock/src/env-graph/lib/data-source.ts | 3 ++- .../env-graph/test/multi-path-loading.test.ts | 4 ++-- packages/varlock/src/lib/load-graph.ts | 17 +++++++++-------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 85b9f6d6..5fac289b 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -934,8 +934,9 @@ export class MultiplePathsContainerDataSource extends EnvGraphDataSource { 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) - || (await pathExists(entryPath) && (await fs.stat(entryPath)).isDirectory()); + || (stat?.isDirectory() ?? false); const child: EnvGraphDataSource = isDirectory ? new DirectoryDataSource(entryPath) diff --git a/packages/varlock/src/env-graph/test/multi-path-loading.test.ts b/packages/varlock/src/env-graph/test/multi-path-loading.test.ts index e41d9319..6da785e4 100644 --- a/packages/varlock/src/env-graph/test/multi-path-loading.test.ts +++ b/packages/varlock/src/env-graph/test/multi-path-loading.test.ts @@ -165,7 +165,7 @@ describe('MultiplePathsContainerDataSource', () => { }, }); - const loadingErrors = g.sortedDataSources.filter((s) => s.loadingError); - expect(loadingErrors).toHaveLength(0); + const sourcesWithLoadingErrors = g.sortedDataSources.filter((s) => s.loadingError); + expect(sourcesWithLoadingErrors).toHaveLength(0); }); }); diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index 2e728e59..d320ee68 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -42,19 +42,20 @@ export function loadVarlockEnvGraph(opts?: { } if (pkgLoadPaths) { - if (pkgLoadPaths.length === 1) { - debug('using path from package.json varlock.loadPath: %s', path.resolve(pkgLoadPaths[0])); + const resolvedLoadPaths = pkgLoadPaths.map((p) => path.resolve(p)); + + if (resolvedLoadPaths.length === 1) { + debug('using path from package.json varlock.loadPath: %s', resolvedLoadPaths[0]); } else { debug( 'using %d paths from package.json varlock.loadPath: %s', - pkgLoadPaths.length, - pkgLoadPaths.map((p) => path.resolve(p)).join(', '), + resolvedLoadPaths.length, + resolvedLoadPaths.join(', '), ); } // Validate that all paths exist - for (const p of pkgLoadPaths) { - const resolvedPath = path.resolve(p); + for (const resolvedPath of resolvedLoadPaths) { if (!fs.existsSync(resolvedPath)) { throw new CliExitError( `A path in \`varlock.loadPath\` configured in package.json does not exist: ${resolvedPath}`, @@ -66,9 +67,9 @@ export function loadVarlockEnvGraph(opts?: { return runWithWorkspaceInfo(() => loadEnvGraph({ ...opts, // For a single path, use the existing entryFilePath option for backward compatibility - entryFilePath: pkgLoadPaths.length === 1 ? pkgLoadPaths[0] : undefined, + entryFilePath: resolvedLoadPaths.length === 1 ? resolvedLoadPaths[0] : undefined, // For multiple paths, use entryFilePaths to trigger the multi-path container - entryFilePaths: pkgLoadPaths.length > 1 ? pkgLoadPaths : undefined, + entryFilePaths: resolvedLoadPaths.length > 1 ? resolvedLoadPaths : undefined, afterInit: async (_g) => { // TODO: register varlock resolver }, From 1b3b760beddd5e7e5b5ceabf7173eaea84b746cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:09:41 +0000 Subject: [PATCH 4/5] Address review: childrenAreRoots on container, multi-file tests, envFilesTest loadPaths support Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/09d7b46b-3d27-4bbb-8af2-f75b75adfc39 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- .../varlock/src/env-graph/lib/data-source.ts | 45 ++- .../env-graph/test/helpers/generic-test.ts | 35 +- .../env-graph/test/multi-path-loading.test.ts | 315 +++++++++--------- 3 files changed, 221 insertions(+), 174 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 5fac289b..8da9dd94 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -100,12 +100,13 @@ export abstract class EnvGraphDataSource { } /** - * 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). + * When true, direct children of this data source are treated as top-level entry points: + * they inherit the "no parent" semantics and are never classified as env-specific + * solely because of their filename (e.g. `.env.production` used as an explicit entry + * point would not be env-specific, but one auto-loaded inside a directory would be). + * Only `MultiplePathsContainerDataSource` sets this to true. */ - isExplicitRoot?: boolean; + childrenAreRoots?: boolean; /** * Whether this data source is environment-specific. @@ -119,12 +120,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, or marked with - * `isExplicitRoot`) 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 whose parent + * has `childrenAreRoots` set) is never treated as env-specific even if its filename + * contains an env qualifier. */ get isEnvSpecific(): boolean { - if (this.applyForEnv && !this.isImport && this.parent && !this.isExplicitRoot) return true; + if (this.applyForEnv && !this.isImport && this.parent && !this.parent?.childrenAreRoots) return true; if (this.type === 'overrides') return true; if (this._hasConditionalDisable) return true; if (this.importMeta?.isConditionallyEnabled) return true; @@ -923,6 +924,12 @@ export class MultiplePathsContainerDataSource extends EnvGraphDataSource { typeLabel = 'multi-path-container'; get label() { return `multi-path container (${this.paths.length} paths)`; } + /** + * Direct children of this container are treated as top-level entry points, + * inheriting root semantics (never classified as env-specific due to filename). + */ + childrenAreRoots = true; + constructor( /** Pre-resolved absolute paths (directories ending with path.sep, or file paths) */ readonly paths: Array, @@ -934,16 +941,18 @@ export class MultiplePathsContainerDataSource extends EnvGraphDataSource { 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; + || (!this.graph.virtualImports && await fs.stat(entryPath).then((s) => s.isDirectory()).catch(() => false)); + + let child: EnvGraphDataSource; + if (isDirectory) { + child = new DirectoryDataSource(entryPath); + } else { + // For file sources, pass through virtual contents when available + const virtualContents = this.graph.virtualImports?.[entryPath]; + const overrideOpts = virtualContents ? { overrideContents: virtualContents } : undefined; + child = new DotEnvFileDataSource(entryPath, overrideOpts); + } await this._initChild(child); // For DirectoryDataSource, imports are handled internally in its own _finishInit. diff --git a/packages/varlock/src/env-graph/test/helpers/generic-test.ts b/packages/varlock/src/env-graph/test/helpers/generic-test.ts index d0601484..6df7d943 100644 --- a/packages/varlock/src/env-graph/test/helpers/generic-test.ts +++ b/packages/varlock/src/env-graph/test/helpers/generic-test.ts @@ -1,7 +1,7 @@ import { expect, vi } from 'vitest'; import path from 'node:path'; import { - EnvGraph, SchemaError, DirectoryDataSource, DotEnvFileDataSource, + EnvGraph, SchemaError, DirectoryDataSource, DotEnvFileDataSource, MultiplePathsContainerDataSource, } from '../../index'; import type { Constructor } from '@env-spec/utils/type-utils'; @@ -12,6 +12,15 @@ import type { Constructor } from '@env-spec/utils/type-utils'; export function envFilesTest(spec: { envFile?: string; files?: Record; + /** + * When provided, overrides the default single-directory loading. + * Can be a single relative path string or an array of relative paths. + * Each path is resolved relative to the current test file's directory. + * Use a trailing `/` (or `path.sep`) to indicate a directory. + * The `files` map should contain entries whose keys start with the + * corresponding path prefix (e.g. `'path1/.env.schema'`). + */ + loadPaths?: string | Array; fallbackEnv?: string, overrideValues?: Record; /** Override process.env for builtin var detection (avoids modifying real process.env) */ @@ -43,8 +52,28 @@ export function envFilesTest(spec: { if (spec.processEnv) g.processEnvOverride = spec.processEnv; if (spec.files) { g.setVirtualImports(currentDir, spec.files); - const source = new DirectoryDataSource(currentDir); - await g.setRootDataSource(source); + + if (spec.loadPaths) { + // Multi-path or explicit single-path loading + const rawPaths = Array.isArray(spec.loadPaths) ? spec.loadPaths : [spec.loadPaths]; + // Preserve trailing slash (path.resolve strips it, but it's used to detect directories) + const resolvedPaths = rawPaths.map((p) => { + const hasTrailingSlash = p.endsWith('/') || p.endsWith(path.sep); + const resolved = path.resolve(currentDir, p); + return hasTrailingSlash ? `${resolved}${path.sep}` : resolved; + }); + if (resolvedPaths.length === 1) { + const rp = resolvedPaths[0]; + const isDir = rp.endsWith('/') || rp.endsWith(path.sep); + const source = isDir ? new DirectoryDataSource(rp) : new DotEnvFileDataSource(rp); + await g.setRootDataSource(source); + } else { + await g.setRootDataSource(new MultiplePathsContainerDataSource(resolvedPaths)); + } + } else { + const source = new DirectoryDataSource(currentDir); + await g.setRootDataSource(source); + } } else if (spec.envFile) { const source = new DotEnvFileDataSource('.env.schema', { overrideContents: spec.envFile }); await g.setRootDataSource(source); diff --git a/packages/varlock/src/env-graph/test/multi-path-loading.test.ts b/packages/varlock/src/env-graph/test/multi-path-loading.test.ts index 6da785e4..80808bc2 100644 --- a/packages/varlock/src/env-graph/test/multi-path-loading.test.ts +++ b/packages/varlock/src/env-graph/test/multi-path-loading.test.ts @@ -1,171 +1,180 @@ 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>; - overrideValues?: Record; - 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; -} +import { DirectoryDataSource } from '../index'; +import { envFilesTest } from './helpers/generic-test'; 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' }, - }); + test('loads items from two separate directories', envFilesTest({ + loadPaths: ['path1/', 'path2/'], + files: { + 'path1/.env.schema': outdent` + ITEM1=from-dir1 + `, + 'path2/.env.schema': outdent` + ITEM2=from-dir2 + `, + }, + expectValues: { + ITEM1: 'from-dir1', + ITEM2: 'from-dir2', + }, + })); + + test('later path has higher precedence than earlier path', envFilesTest({ + loadPaths: ['path1/', 'path2/'], + files: { + 'path1/.env.schema': outdent` + SHARED_ITEM=from-dir1 + `, + 'path2/.env.schema': outdent` + SHARED_ITEM=from-dir2 + `, + }, + expectValues: { + // dir2 (last path) has higher precedence + SHARED_ITEM: 'from-dir2', + }, + })); + + test('env-specific files are loaded per directory', envFilesTest({ + loadPaths: ['path1/', 'path2/'], + files: { + 'path1/.env.schema': outdent` + # @currentEnv=$APP_ENV + # --- + APP_ENV=dev + ITEM1=from-dir1-schema + `, + 'path1/.env.production': outdent` + ITEM1=from-dir1-prod + `, + 'path2/.env.schema': outdent` + ITEM2=from-dir2-schema + `, + }, + overrideValues: { APP_ENV: 'production' }, + expectValues: { + ITEM1: 'from-dir1-prod', + ITEM2: 'from-dir2-schema', + }, + })); + + test('loads items from two separate .env files (not directories)', envFilesTest({ + loadPaths: ['path1/.env.schema', 'path2/.env.schema'], + files: { + 'path1/.env.schema': outdent` + ITEM1=from-file1 + `, + 'path2/.env.schema': outdent` + ITEM2=from-file2 + `, + }, + expectValues: { + ITEM1: 'from-file1', + ITEM2: 'from-file2', + }, + })); + + test('later file path has higher precedence than earlier file path', envFilesTest({ + loadPaths: ['path1/.env.schema', 'path2/.env.schema'], + files: { + 'path1/.env.schema': outdent` + SHARED_ITEM=from-file1 + `, + 'path2/.env.schema': outdent` + SHARED_ITEM=from-file2 + `, + }, + expectValues: { + // file2 (last path) has higher precedence + SHARED_ITEM: 'from-file2', + }, + })); + + test('directory children of container are not env-specific, but their auto-loaded env files are', envFilesTest({ + loadPaths: ['path1/', 'path2/'], + files: { + 'path1/.env.schema': outdent` + # @currentEnv=$APP_ENV + # --- + APP_ENV=dev + ITEM1=from-dir1-schema + `, + 'path1/.env.production': outdent` + ITEM1=from-dir1-prod + `, + 'path2/.env.schema': outdent` + ITEM2=from-dir2 + `, + }, + overrideValues: { APP_ENV: 'production' }, + expectSerializedMatches: { + // Just verify loading works; env-specific tests are structural + }, + })); + + test('single path in array behaves like regular directory loading', envFilesTest({ + loadPaths: ['path1/'], + files: { + 'path1/.env.schema': outdent` + ITEM1=from-dir1 + `, + 'path1/.env': outdent` + ITEM1=from-dir1-env + `, + }, + expectValues: { + // .env overrides .env.schema + ITEM1: 'from-dir1-env', + }, + })); +}); - await g.resolveEnvValues(); +describe('MultiplePathsContainerDataSource - isEnvSpecific behavior', () => { + test('directory children of container are NOT env-specific', async () => { + const { EnvGraph, MultiplePathsContainerDataSource } = await import('../index'); + const g = new EnvGraph(); - expect(g.configSchema.ITEM1?.resolvedValue).toBe('from-dir1-prod'); - expect(g.configSchema.ITEM2?.resolvedValue).toBe('from-dir2-schema'); - }); + g.virtualImports = { + '/vt/dir1/.env.schema': 'ITEM1=val1', + '/vt/dir2/.env.schema': 'ITEM2=val2', + }; - 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 - `, - }, - }, - }); + await g.setRootDataSource(new MultiplePathsContainerDataSource(['/vt/dir1/', '/vt/dir2/'])); + await g.finishLoad(); - // The root DirectoryDataSource children should NOT be env-specific const rootChildren = g.rootDataSource?.children ?? []; for (const child of rootChildren) { + expect(child).toBeInstanceOf(DirectoryDataSource); + // DirectoryDataSource children of the container are not env-specific 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); + test('env-specific files auto-loaded inside directory children ARE env-specific', async () => { + const { EnvGraph, MultiplePathsContainerDataSource } = await import('../index'); + const g = new EnvGraph(); + + g.virtualImports = { + '/vt/dir1/.env.schema': outdent` + # @currentEnv=$APP_ENV + # --- + APP_ENV=dev + ITEM1=val1 + `, + '/vt/dir1/.env.production': 'ITEM1=prod-val1', + }; + + g.overrideValues = { APP_ENV: 'production' }; + await g.setRootDataSource(new MultiplePathsContainerDataSource(['/vt/dir1/'])); + await g.finishLoad(); + + // Find .env.production source (it's inside dir1's children) + const allSources = g.sortedDataSources; + const envProdSource = allSources.find((s) => 'fileName' in s && (s as any).fileName === '.env.production'); + expect(envProdSource).toBeDefined(); + expect(envProdSource!.isEnvSpecific).toBe(true); }); }); From 5037cca939b593dafc558ef98957b46a43457de8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:11:57 +0000 Subject: [PATCH 5/5] Simplify intermediate variable in MultiplePathsContainerDataSource Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/09d7b46b-3d27-4bbb-8af2-f75b75adfc39 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- packages/varlock/src/env-graph/lib/data-source.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 8da9dd94..82028d3c 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -950,8 +950,7 @@ export class MultiplePathsContainerDataSource extends EnvGraphDataSource { } else { // For file sources, pass through virtual contents when available const virtualContents = this.graph.virtualImports?.[entryPath]; - const overrideOpts = virtualContents ? { overrideContents: virtualContents } : undefined; - child = new DotEnvFileDataSource(entryPath, overrideOpts); + child = new DotEnvFileDataSource(entryPath, virtualContents ? { overrideContents: virtualContents } : undefined); } await this._initChild(child);