Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dotenv #1221

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft

Dotenv #1221

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 package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions packages/dotenv-extends/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/node_modules/
/src/
/tests/
/artifacts/
tsconfig.json
tsconfig.*.json
tsconfig.tsbuildinfo
env.production
env.development
env.test
.env.*
webpack.*
*.ipynb
captchas_*.json
data.json
stl10/*.json
stl10
3 changes: 3 additions & 0 deletions packages/dotenv-extends/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Dotenv with extends support

Allows `.env` files to extend other `.env` files, enabling inheritance!
49 changes: 49 additions & 0 deletions packages/dotenv-extends/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@prosopo/dotenv-extends",
"version": "0.3.5",
"description": "Dotenv with extends support",
"main": "./dist/index.js",
"type": "module",
"engines": {
"node": ">=18",
"npm": ">=9"
},
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/cjs/index.cjs"
}
},
"types": "./dist/index.d.ts",
"scripts": {
"clean": "tsc --build --clean",
"build": "tsc --build --verbose",
"build:cjs": "npx vite --config vite.cjs.config.ts build",
"test": "NODE_ENV=test vitest --run",
"eslint": "npx eslint . --no-error-on-unmatched-pattern --ignore-path ../../.eslintignore",
"eslint:fix": "npm run eslint -- --fix",
"prettier": "npx prettier . --check --no-error-on-unmatched-pattern --ignore-path ../../.eslintignore",
"prettier:fix": "npm run prettier -- --write",
"lint": "npm run eslint && npm run prettier",
"lint:fix": "npm run eslint:fix && npm run prettier:fix"
},
"author": "Prosopo Limited",
"license": "Apache-2.0",
"devDependencies": {
"tslib": "2.6.2",
"typescript": "5.1.6",
"vitest": "^1.3.1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/prosopo/captcha.git"
},
"bugs": {
"url": "https://github.com/prosopo/captcha/issues"
},
"homepage": "https://github.com/prosopo/captcha#readme",
"publishConfig": {
"registry": "https://registry.npmjs.org"
},
"sideEffects": false
}
180 changes: 180 additions & 0 deletions packages/dotenv-extends/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright 2021-2024 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import dotenv from 'dotenv'
import fs from 'fs'
import path from 'path'

const parseFileList = (str: string): string[] => {
// TODO json?
return str.split(',').map((s) => s.trim())
}

// an env file can optionally inherit off other env file(s)
// so when we load an env file, we need to load the inherited env files first, then the env file itself
// if multiple env files are specified, we have to load them in order

type Node = {
envFile: string
extends: Node[]
origin: Node | null
}

const getEnvFileGraph = (envFilePath: string): Node => {
// the env hierarchy can be represented as a acyclic graph
// extends links to nodes which get loaded before the env file
// origin is the node which refers to this node
const root: Node = {
envFile: envFilePath,
extends: [],
origin: null,
}
const queue: Node[] = [root]
while (queue.length > 0) {
const node = queue.pop()
if (node === undefined) {
throw new Error('Unexpected undefined node') // this should never happen
}
// if the node has an origin, we need to check there is no circular reference between any of the nodes
// this would manifest by this node having the same env file as an origin node
let origin = node.origin
const origins = []
while (origin !== null) {
origins.push(origin)
if (origin.envFile === node.envFile) {
throw new Error(
`Circular env file reference detected: ${origins
.reverse()
.concat([origin])
.map((n) => n.envFile)
.join(` -> `)}`
)
}
origin = origin.origin
}
// read the env file
if (!fs.existsSync(node.envFile)) {
throw new Error(`Env file does not exist: ${node.envFile}`)
}
const env: any = {}

Check warning on line 69 in packages/dotenv-extends/src/index.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected any. Specify a different type
dotenv.config({
path: node.envFile,
processEnv: env,
})
// check if the file extends another file
const ext = env['EXTENDS']
if (ext !== undefined) {
const extFiles = parseFileList(ext)
// resolve the path to the extended file
const resolvedExtFiles = extFiles.map((f) => {
if (path.isAbsolute(f)) {
return f
}
return path.resolve(path.dirname(node.envFile), f)
})
for (const extFile of resolvedExtFiles) {
const extNode: Node = {
envFile: extFile,
extends: [],
origin: node,
}
node.extends.push(extNode)
// add the node to the queue so we visit it in the future
queue.push(extNode)
}
}
}
return root
}

const nodeToStringArray = (node: Node): string[] => {
const strs = [node.envFile]
for (const ext of node.extends) {
strs.push(...nodeToStringArray(ext).map((s) => `\t${s}`))
}
return strs
}

const nodeToString = (node: Node): string => {
return nodeToStringArray(node).join('\n')
}

const expandEnvFile = (envFilePath: string, args: Args): string[] => {
const root = getEnvFileGraph(envFilePath)
if (args.debug) {
console.log('env file graph\n', nodeToString(root))
}

// we need to load them in order
// iterate over the graph in a depth first search
const envFiles: string[] = []
const stack: Node[] = [root]
while (stack.length > 0) {
const node = stack.pop()
if (node === undefined) {
throw new Error('Unexpected undefined node') // this should never happen
}
envFiles.push(node.envFile)
stack.push(...node.extends.reverse())
}

return envFiles.reverse()
}

export type Args = dotenv.DotenvConfigOptions

export default function config(args?: Args): {
[key: string]: string | undefined
} {
args = args || {}
args.path = args.path || '.env'

const envFiles: string[] = []
if (Array.isArray(args.path)) {
envFiles.push(...args.path.map((p) => p.toString()))
} else {
envFiles.push(args.path.toString())
}

// expand the env files
// this looks for EXTENDS directives in the env files
// and loads the files in the correct order
const expandedEnvFiles: string[] = []
for (const envFile of envFiles) {
expandedEnvFiles.push(...expandEnvFile(envFile, { ...args, path: undefined }))
}

// load the env files in order into the process env / destination
const dest: {
[key: string]: string | undefined
} = {}
for (const envFile of expandedEnvFiles) {
dotenv.config({
...args,
path: envFile,
processEnv: dest as dotenv.DotenvPopulateInput,
override: true,
})
}

// delete extends field
delete dest['EXTENDS']

// assign the destination to the process env
const env = args.processEnv || process.env
for (const [key, value] of Object.entries(dest)) {
env[key] = value
}

return dest
}
1 change: 1 addition & 0 deletions packages/dotenv-extends/src/tests/circular/a.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
EXTENDS=b.env
1 change: 1 addition & 0 deletions packages/dotenv-extends/src/tests/circular/b.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
EXTENDS=a.env
1 change: 1 addition & 0 deletions packages/dotenv-extends/src/tests/circular/env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
EXTENDS=a.env
76 changes: 76 additions & 0 deletions packages/dotenv-extends/src/tests/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright 2021-2024 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, expect, it } from 'vitest'
import config from '../index.js'

describe('env', () => {
it('loads env files with extends', () => {
expect(
config({
path: __dirname + '/extends/root.env',
debug: true,
})
).to.deep.equal({
root: 'true',
'root.0': 'true',
'root.1': 'true',
'root.2': 'true',
'root.0.0': 'true',
'root.0.1': 'true',
})
})

it('errors on circular extends', () => {
expect(() =>
config({
path: __dirname + '/circular/env',
})
).to.throw()
})

it('overrides extended values', () => {
expect(
config({
path: __dirname + '/override/a.env',
})
).to.deep.equal({
A: 'true',
B: 'true',
C: 'false',
D: 'false',
E: 'true',
})
})

it('loads relative', () => {
process.chdir(__dirname)

Check failure on line 57 in packages/dotenv-extends/src/tests/env.test.ts

View workflow job for this annotation

GitHub Actions / check

src/tests/env.test.ts > env > loads relative

TypeError: process.chdir() is not supported in workers ❯ src/tests/env.test.ts:57:17 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_WORKER_UNSUPPORTED_OPERATION' }

Check failure on line 57 in packages/dotenv-extends/src/tests/env.test.ts

View workflow job for this annotation

GitHub Actions / check

src/tests/env.test.ts > env > loads relative

TypeError: process.chdir() is not supported in workers ❯ src/tests/env.test.ts:57:17 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_WORKER_UNSUPPORTED_OPERATION' }
expect(
config({
path: './override/c.env',
})
).to.deep.equal({
C: 'true',
D: 'true',
E: 'true',
})
})

it('errors on missing file', () => {
expect(() =>
config({
path: './src/tests/missing.env',
})
).to.throw()
})
})
1 change: 1 addition & 0 deletions packages/dotenv-extends/src/tests/extends/root.0.0.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root.0.0=true
1 change: 1 addition & 0 deletions packages/dotenv-extends/src/tests/extends/root.0.1.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root.0.1=true
2 changes: 2 additions & 0 deletions packages/dotenv-extends/src/tests/extends/root.0.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
EXTENDS=root.0.0.env, root.0.1.env
root.0=true
1 change: 1 addition & 0 deletions packages/dotenv-extends/src/tests/extends/root.1.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root.1=true
1 change: 1 addition & 0 deletions packages/dotenv-extends/src/tests/extends/root.2.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root.2=true
2 changes: 2 additions & 0 deletions packages/dotenv-extends/src/tests/extends/root.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
EXTENDS=root.0.env, root.1.env, root.2.env
root=true
1 change: 1 addition & 0 deletions packages/dotenv-extends/src/tests/missing.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
EXTENDS=path/to/some/missing/file.env
3 changes: 3 additions & 0 deletions packages/dotenv-extends/src/tests/override/a.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
EXTENDS=b.env
A=true
C=false
Loading
Loading