Skip to content
Open
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
23 changes: 23 additions & 0 deletions extension.js
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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 () {
Expand Down
132 changes: 129 additions & 3 deletions lib/helpers.js
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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
}
Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions test/suite/examples/monorepo/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ROOT_VAR=root
SHARED_VAR=from_root
2 changes: 2 additions & 0 deletions test/suite/examples/monorepo/packages/app-a/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
APP_A_VAR=appA
SHARED_VAR=from_app_a
1 change: 1 addition & 0 deletions test/suite/examples/monorepo/packages/app-a/.env.local
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
LOCAL_VAR=local_value
2 changes: 2 additions & 0 deletions test/suite/examples/monorepo/packages/app-a/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Test file for monorepo support
console.log(process.env.APP_A_VAR)
2 changes: 2 additions & 0 deletions test/suite/examples/monorepo/packages/app-b/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Test file for monorepo support - no local .env, should use root
console.log(process.env.ROOT_VAR)
99 changes: 96 additions & 3 deletions test/suite/lib/helpers.test.js
Original file line number Diff line number Diff line change
@@ -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 () {
Expand All @@ -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()
})
})
})
})