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..0a518440 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -99,6 +99,15 @@ export abstract class EnvGraphDataSource { await child._processImports(); } + /** + * 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. + */ + childrenAreRoots?: boolean; + /** * Whether this data source is environment-specific. * A source is env-specific if: @@ -111,11 +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) 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) 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; @@ -902,3 +912,54 @@ 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)`; } + + /** + * 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, + ) { + 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) + || (!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]; + child = new DotEnvFileDataSource( + entryPath, + virtualContents ? { overrideContents: virtualContents } : undefined, + ); + } + + 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/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 new file mode 100644 index 00000000..80808bc2 --- /dev/null +++ b/packages/varlock/src/env-graph/test/multi-path-loading.test.ts @@ -0,0 +1,180 @@ +import { + describe, test, expect, +} from 'vitest'; +import outdent from 'outdent'; +import { DirectoryDataSource } from '../index'; +import { envFilesTest } from './helpers/generic-test'; + +describe('MultiplePathsContainerDataSource', () => { + 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', + }, + })); +}); + +describe('MultiplePathsContainerDataSource - isEnvSpecific behavior', () => { + test('directory children of container are NOT env-specific', async () => { + const { EnvGraph, MultiplePathsContainerDataSource } = await import('../index'); + const g = new EnvGraph(); + + g.virtualImports = { + '/vt/dir1/.env.schema': 'ITEM1=val1', + '/vt/dir2/.env.schema': 'ITEM2=val2', + }; + + await g.setRootDataSource(new MultiplePathsContainerDataSource(['/vt/dir1/', '/vt/dir2/'])); + await g.finishLoad(); + + 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('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); + }); +}); diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index 183075e0..d320ee68 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -14,35 +14,72 @@ 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) { + 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', + resolvedLoadPaths.length, + resolvedLoadPaths.join(', '), + ); + } + + // Validate that all paths exist + 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}`, + { 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: resolvedLoadPaths.length === 1 ? resolvedLoadPaths[0] : undefined, + // For multiple paths, use entryFilePaths to trigger the multi-path container + entryFilePaths: resolvedLoadPaths.length > 1 ? resolvedLoadPaths : 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; }; /**