diff --git a/extension.js b/extension.js index 212a597..64c03c0 100644 --- a/extension.js +++ b/extension.js @@ -1,8 +1,10 @@ +const vscode = require('vscode') const autocloaking = require('./lib/autocloaking') const autocompletion = require('./lib/autocompletion') const commands = require('./lib/commands') const peeking = require('./lib/peeking') const fileAssociations = require('./lib/fileAssociations') +const helpers = require('./lib/helpers') async function activate (context) { console.log('Dotenv is active') @@ -21,6 +23,27 @@ async function activate (context) { console.log('Load autocloaking') await autocloaking.run(context) + + // Watch for .env file changes to invalidate cache + console.log('Setup .env file watcher') + const envFileWatcher = vscode.workspace.createFileSystemWatcher('**/.env*') + + envFileWatcher.onDidCreate(() => { + console.log('.env file created, clearing cache') + helpers.clearEnvFileCache() + }) + + envFileWatcher.onDidDelete(() => { + console.log('.env file deleted, clearing cache') + helpers.clearEnvFileCache() + }) + + envFileWatcher.onDidChange(() => { + console.log('.env file changed, clearing cache') + helpers.clearEnvFileCache() + }) + + context.subscriptions.push(envFileWatcher) } function deactivate () { diff --git a/lib/helpers.js b/lib/helpers.js index 5de7328..c3851d8 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -1,7 +1,109 @@ const vscode = require('vscode') const dotenv = require('dotenv') +const fs = require('fs') +const path = require('path') const settings = require('./settings') +// Memoization cache: maps directory path to found .env file path (or null if none found) +const envFileCache = new Map() + +// Priority order for .env files (higher priority first) +const ENV_FILE_PRIORITY = ['.env.local', '.env'] + +/** + * Get all .env* files in a directory, sorted by priority + * @param {string} dirPath - Directory to search + * @returns {string[]} Array of .env file paths, sorted by priority + */ +function getEnvFilesInDirectory (dirPath) { + try { + const files = fs.readdirSync(dirPath) + const envFiles = files.filter(file => file.startsWith('.env')) + + // Sort by priority: .env.local first, then .env, then others alphabetically + return envFiles.sort((a, b) => { + const priorityA = ENV_FILE_PRIORITY.indexOf(a) + const priorityB = ENV_FILE_PRIORITY.indexOf(b) + + // If both are in priority list, sort by priority + if (priorityA !== -1 && priorityB !== -1) { + return priorityA - priorityB + } + // If only a is in priority list, a comes first + if (priorityA !== -1) return -1 + // If only b is in priority list, b comes first + if (priorityB !== -1) return 1 + // Otherwise sort alphabetically + return a.localeCompare(b) + }).map(file => path.join(dirPath, file)) + } catch (err) { + return [] + } +} + +/** + * Find the nearest .env* file starting from startPath and walking up the directory tree + * @param {string} startPath - Starting directory path + * @returns {string|null} Path to the nearest .env file, or null if not found + */ +function findNearestEnvFile (startPath) { + // Check cache first + if (envFileCache.has(startPath)) { + return envFileCache.get(startPath) + } + + // Get workspace root as boundary (don't go above it) + const workspaceRoot = vscode.workspace.workspaceFolders + ? vscode.workspace.workspaceFolders[0].uri.fsPath + : null + + let currentDir = startPath + const visitedDirs = [] + + while (currentDir) { + visitedDirs.push(currentDir) + + const envFiles = getEnvFilesInDirectory(currentDir) + if (envFiles.length > 0) { + const envFilePath = envFiles[0] // Use highest priority file + + // Cache result for all visited directories + for (const dir of visitedDirs) { + envFileCache.set(dir, envFilePath) + } + + return envFilePath + } + + // Stop if we've reached workspace root or filesystem root + if (workspaceRoot && currentDir === workspaceRoot) { + break + } + + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir) { + // Reached filesystem root + break + } + + currentDir = parentDir + } + + // No .env file found - cache null for all visited directories + for (const dir of visitedDirs) { + envFileCache.set(dir, null) + } + + return null +} + +/** + * Clear the env file cache. Called when .env files change. + */ +function clearEnvFileCache () { + envFileCache.clear() +} + function envEntries () { const parsed = envParsed() @@ -15,12 +117,33 @@ function envEntries () { } function envParsed () { - const workspacePath = vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders[0].uri.fsPath.replace(/\\/g, '/') : undefined // /path/to/project/folder + let envFilePath = null + + // Try monorepo support first if enabled + if (settings.monorepoSupportEnabled()) { + const activeEditor = vscode.window.activeTextEditor + if (activeEditor) { + const documentPath = activeEditor.document.uri.fsPath + const documentDir = path.dirname(documentPath) + envFilePath = findNearestEnvFile(documentDir) + } + } + + // Fall back to workspace root if no file found or monorepo support disabled + if (!envFilePath) { + const workspacePath = vscode.workspace.workspaceFolders + ? vscode.workspace.workspaceFolders[0].uri.fsPath.replace(/\\/g, '/') + : undefined + + if (workspacePath) { + envFilePath = path.join(workspacePath, '.env') + } + } let parsed = {} - if (workspacePath) { - parsed = dotenv.config({ path: `${workspacePath}/.env` }).parsed + if (envFilePath) { + parsed = dotenv.config({ path: envFilePath }).parsed } else { parsed = dotenv.config().parsed } @@ -124,3 +247,6 @@ module.exports.envEntries = envEntries module.exports.envParsed = envParsed module.exports.hover = hover module.exports.autocomplete = autocomplete +module.exports.findNearestEnvFile = findNearestEnvFile +module.exports.getEnvFilesInDirectory = getEnvFilesInDirectory +module.exports.clearEnvFileCache = clearEnvFileCache diff --git a/lib/settings.js b/lib/settings.js index b012a45..8c202c8 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -6,6 +6,7 @@ const enableAutocloakingKey = 'dotenv.enableAutocloaking' const cloakColorKey = 'dotenv.cloakColor' const cloakIconKey = 'dotenv.cloakIcon' const enableSecretpeekingKey = 'dotenv.enableSecretpeeking' +const enableMonorepoSupportKey = 'dotenv.enableMonorepoSupport' // other settings from vscode or other extensions const editorTokenColorCustomizationsKey = 'editor.tokenColorCustomizations' @@ -72,6 +73,12 @@ function cloakIcon () { return userConfig().get(cloakIconKey) } +function monorepoSupportEnabled () { + const value = userConfig().get(enableMonorepoSupportKey) + // Default to true if not explicitly set + return value === undefined ? true : !!value +} + // other settings function _editorTokenColorCustomizations () { return userConfig().get(editorTokenColorCustomizationsKey) @@ -132,6 +139,7 @@ module.exports.autocloakingEnabled = autocloakingEnabled module.exports.secretpeekingEnabled = secretpeekingEnabled module.exports.cloakColor = cloakColor module.exports.cloakIcon = cloakIcon +module.exports.monorepoSupportEnabled = monorepoSupportEnabled // other module.exports.missingText = missingText diff --git a/package.json b/package.json index a86e85b..4517300 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,12 @@ "type": "string", "default": "█", "description": "Change the icon of the cloak for your .env files" + }, + "dotenv.enableMonorepoSupport": { + "scope": "resource", + "type": "boolean", + "default": true, + "description": "When enabled, searches for .env files starting from the active file's directory and walking up the tree. When disabled, uses workspace root." } } }, diff --git a/test/suite/examples/monorepo/.env b/test/suite/examples/monorepo/.env new file mode 100644 index 0000000..a4a784a --- /dev/null +++ b/test/suite/examples/monorepo/.env @@ -0,0 +1,2 @@ +ROOT_VAR=root +SHARED_VAR=from_root diff --git a/test/suite/examples/monorepo/packages/app-a/.env b/test/suite/examples/monorepo/packages/app-a/.env new file mode 100644 index 0000000..114d10d --- /dev/null +++ b/test/suite/examples/monorepo/packages/app-a/.env @@ -0,0 +1,2 @@ +APP_A_VAR=appA +SHARED_VAR=from_app_a diff --git a/test/suite/examples/monorepo/packages/app-a/.env.local b/test/suite/examples/monorepo/packages/app-a/.env.local new file mode 100644 index 0000000..30a9c3c --- /dev/null +++ b/test/suite/examples/monorepo/packages/app-a/.env.local @@ -0,0 +1 @@ +LOCAL_VAR=local_value diff --git a/test/suite/examples/monorepo/packages/app-a/src/index.js b/test/suite/examples/monorepo/packages/app-a/src/index.js new file mode 100644 index 0000000..9da5ccd --- /dev/null +++ b/test/suite/examples/monorepo/packages/app-a/src/index.js @@ -0,0 +1,2 @@ +// Test file for monorepo support +console.log(process.env.APP_A_VAR) diff --git a/test/suite/examples/monorepo/packages/app-b/src/index.js b/test/suite/examples/monorepo/packages/app-b/src/index.js new file mode 100644 index 0000000..3f4f52c --- /dev/null +++ b/test/suite/examples/monorepo/packages/app-b/src/index.js @@ -0,0 +1,2 @@ +// Test file for monorepo support - no local .env, should use root +console.log(process.env.ROOT_VAR) diff --git a/test/suite/lib/helpers.test.js b/test/suite/lib/helpers.test.js index 9e48e9d..7490684 100644 --- a/test/suite/lib/helpers.test.js +++ b/test/suite/lib/helpers.test.js @@ -1,10 +1,14 @@ -const mocha = require('mocha') -const describe = mocha.describe -const it = mocha.it const assert = require('assert') +const path = require('path') const helpers = require('../../../lib/helpers') +// Path to test fixtures +const monorepoRoot = path.join(__dirname, '..', 'examples', 'monorepo') +const appADir = path.join(monorepoRoot, 'packages', 'app-a') +const appASrcDir = path.join(appADir, 'src') +const appBSrcDir = path.join(monorepoRoot, 'packages', 'app-b', 'src') + describe('helpers', function () { describe('#envParsed', function () { it('returns envParsed', function () { @@ -21,4 +25,93 @@ describe('helpers', function () { assert.deepEqual(result[0], ['HELLO', 'World']) }) }) + + describe('#getEnvFilesInDirectory', function () { + it('returns empty array for directory with no .env files', function () { + const result = helpers.getEnvFilesInDirectory(appASrcDir) + assert.deepEqual(result, []) + }) + + it('returns all .env* files in directory', function () { + const result = helpers.getEnvFilesInDirectory(appADir) + assert.strictEqual(result.length, 2) + // Files should be present + assert.ok(result.some(f => f.endsWith('.env'))) + assert.ok(result.some(f => f.endsWith('.env.local'))) + }) + + it('prioritizes .env.local over .env', function () { + const result = helpers.getEnvFilesInDirectory(appADir) + const envLocalIndex = result.findIndex(f => f.endsWith('.env.local')) + const envIndex = result.findIndex(f => f.endsWith('.env') && !f.endsWith('.env.local')) + assert.ok(envLocalIndex < envIndex, '.env.local should come before .env') + }) + + it('returns .env files in monorepo root', function () { + const result = helpers.getEnvFilesInDirectory(monorepoRoot) + assert.strictEqual(result.length, 1) + assert.ok(result[0].endsWith('.env')) + }) + }) + + describe('#findNearestEnvFile', function () { + beforeEach(function () { + // Clear cache before each test + helpers.clearEnvFileCache() + }) + + it('returns .env in same directory when present', function () { + const result = helpers.findNearestEnvFile(appADir) + // Should find .env.local (higher priority) in app-a directory + assert.ok(result.endsWith('.env.local')) + assert.ok(result.includes('app-a')) + }) + + it('walks up to find .env in parent directory', function () { + // app-b/src has no .env, should walk up to monorepo root + const result = helpers.findNearestEnvFile(appBSrcDir) + assert.ok(result !== null) + assert.ok(result.endsWith('.env')) + assert.ok(result.includes('monorepo')) + assert.ok(!result.includes('app-b')) + }) + + it('returns null when no .env found', function () { + // Use a directory that doesn't exist or has no .env in tree + const result = helpers.findNearestEnvFile('/tmp') + assert.strictEqual(result, null) + }) + + it('caches results for faster subsequent lookups', function () { + // First call + helpers.findNearestEnvFile(appASrcDir) + + // Second call should use cache (we can't easily verify this, + // but we can verify it returns the same result) + const result = helpers.findNearestEnvFile(appASrcDir) + assert.ok(result !== null) + assert.ok(result.includes('app-a')) + }) + + it('cache can be cleared', function () { + // Populate cache + helpers.findNearestEnvFile(appASrcDir) + + // Clear cache + helpers.clearEnvFileCache() + + // Should still work after clearing (will repopulate cache) + const result = helpers.findNearestEnvFile(appASrcDir) + assert.ok(result !== null) + }) + }) + + describe('#clearEnvFileCache', function () { + it('clears the cache without throwing', function () { + // Should not throw + assert.doesNotThrow(() => { + helpers.clearEnvFileCache() + }) + }) + }) })