Skip to content

Commit

Permalink
refactor: assemble dotenv utils
Browse files Browse the repository at this point in the history
continues google#1034
  • Loading branch information
antongolub committed Dec 28, 2024
1 parent 18d8e9a commit a95c63b
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 119 deletions.
18 changes: 18 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,21 @@ The [yaml](https://www.npmjs.com/package/yaml) package.
```js
console.log(YAML.parse('foo: bar').foo)
```

## dotenv
[dotenv](https://www.npmjs.com/package/dotenv)-like environment variables loading API

```js
// parse
const raw = 'FOO=BAR\nBAZ=QUX'
const data = dotenv.parse(raw) // {FOO: 'BAR', BAZ: 'QUX'}
await fs.writeFile('.env', raw)

// load
const env = dotenv.load('.env')
await $({ env })`echo $FOO`.stdout // BAR

// config
dotenv.config('.env')
process.env.FOO // BAR
```
14 changes: 0 additions & 14 deletions docs/v7/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,3 @@ The [yaml](https://www.npmjs.com/package/yaml) package.
```js
console.log(YAML.parse('foo: bar').foo)
```


## loadDotenv

Read env files and collects it into environment variables.

```js
const env = loadDotenv(env1, env2)
console.log((await $({ env })`echo $FOO`).stdout)
---
const env = loadDotenv(env1)
$.env = env
console.log((await $`echo $FOO`).stdout)
```
9 changes: 5 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@ import url from 'node:url'
import {
$,
ProcessOutput,
parseArgv,
updateArgv,
fetch,
chalk,
dotenv,
fetch,
fs,
path,
VERSION,
parseArgv,
} from './index.js'
import { installDeps, parseDeps } from './deps.js'
import { readEnvFromFile, randomId } from './util.js'
import { randomId } from './util.js'
import { createRequire } from './vendor.js'

const EXT = '.mjs'
Expand Down Expand Up @@ -89,7 +90,7 @@ export async function main() {
if (argv.cwd) $.cwd = argv.cwd
if (argv.env) {
const envPath = path.resolve($.cwd ?? process.cwd(), argv.env)
$.env = readEnvFromFile(envPath, process.env)
$.env = { ...process.env, ...dotenv.load(envPath) }
}
if (argv.verbose) $.verbose = true
if (argv.quiet) $.quiet = true
Expand Down
46 changes: 42 additions & 4 deletions src/goods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@

import assert from 'node:assert'
import { createInterface } from 'node:readline'
import { default as path } from 'node:path'
import { $, within, ProcessOutput } from './core.js'
import {
type Duration,
identity,
isStringLiteral,
parseBool,
parseDuration,
readEnvFromFile,
toCamelCase,
} from './util.js'
import {
fs,
minimist,
nodeFetch,
type RequestInfo,
Expand Down Expand Up @@ -220,8 +221,45 @@ export async function spinner<T>(
}

/**
*
* Read env files and collects it into environment variables
*/
export const loadDotenv = (...files: string[]): NodeJS.ProcessEnv =>
files.reduce<NodeJS.ProcessEnv>((m, f) => readEnvFromFile(f, m), {})
export const dotenv = (() => {
const parse = (content: string | Buffer): NodeJS.ProcessEnv =>
content
.toString()
.split(/\r?\n/)
.reduce<NodeJS.ProcessEnv>((r, line) => {
if (line.startsWith('export ')) line = line.slice(7)
const i = line.indexOf('=')
const k = line.slice(0, i).trim()
const v = line.slice(i + 1).trim()
if (k && v) r[k] = v
return r
}, {})

const _load = (
read: (file: string) => string,
...files: string[]
): NodeJS.ProcessEnv =>
files
.reverse()
.reduce((m, f) => Object.assign(m, parse(read(path.resolve(f)))), {})
const load = (...files: string[]): NodeJS.ProcessEnv =>
_load((file) => fs.readFileSync(file, 'utf8'), ...files)
const loadSafe = (...files: string[]): NodeJS.ProcessEnv =>
_load(
(file: string): string =>
fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '',
...files
)

const config = (def = '.env', ...files: string[]): NodeJS.ProcessEnv =>
Object.assign(process.env, loadSafe(def, ...files))

return {
parse,
load,
loadSafe,
config,
}
})()
22 changes: 0 additions & 22 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,25 +357,3 @@ export const toCamelCase = (str: string) =>

export const parseBool = (v: string): boolean | string =>
({ true: true, false: false })[v] ?? v

export const parseDotenv = (content: string): NodeJS.ProcessEnv =>
content.split(/\r?\n/).reduce<NodeJS.ProcessEnv>((r, line) => {
if (line.startsWith('export ')) line = line.slice(7)
const i = line.indexOf('=')
const k = line.slice(0, i).trim()
const v = line.slice(i + 1).trim()
if (k && v) r[k] = v
return r
}, {})

export const readEnvFromFile = (
filepath: string,
env: NodeJS.ProcessEnv = process.env
): NodeJS.ProcessEnv => {
const content = fs.readFileSync(path.resolve(filepath), 'utf8')

return {
...env,
...parseDotenv(content),
}
}
6 changes: 5 additions & 1 deletion test/export.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ describe('index', () => {
assert.equal(typeof index.defaults.sync, 'boolean', 'index.defaults.sync')
assert.equal(typeof index.defaults.timeoutSignal, 'string', 'index.defaults.timeoutSignal')
assert.equal(typeof index.defaults.verbose, 'boolean', 'index.defaults.verbose')
assert.equal(typeof index.dotenv, 'object', 'index.dotenv')
assert.equal(typeof index.dotenv.config, 'function', 'index.dotenv.config')
assert.equal(typeof index.dotenv.load, 'function', 'index.dotenv.load')
assert.equal(typeof index.dotenv.loadSafe, 'function', 'index.dotenv.loadSafe')
assert.equal(typeof index.dotenv.parse, 'function', 'index.dotenv.parse')
assert.equal(typeof index.echo, 'function', 'index.echo')
assert.equal(typeof index.expBackoff, 'function', 'index.expBackoff')
assert.equal(typeof index.fetch, 'function', 'index.fetch')
Expand Down Expand Up @@ -331,7 +336,6 @@ describe('index', () => {
assert.equal(typeof index.globby.isGitIgnored, 'function', 'index.globby.isGitIgnored')
assert.equal(typeof index.globby.isGitIgnoredSync, 'function', 'index.globby.isGitIgnoredSync')
assert.equal(typeof index.kill, 'function', 'index.kill')
assert.equal(typeof index.loadDotenv, 'function', 'index.loadDotenv')
assert.equal(typeof index.log, 'function', 'index.log')
assert.equal(typeof index.minimist, 'function', 'index.minimist')
assert.equal(typeof index.nothrow, 'function', 'index.nothrow')
Expand Down
91 changes: 60 additions & 31 deletions test/goods.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import assert from 'node:assert'
import { test, describe, after } from 'node:test'
import { $, chalk, fs, tempfile } from '../build/index.js'
import { echo, sleep, parseArgv, loadDotenv } from '../build/goods.js'
import { echo, sleep, parseArgv, dotenv } from '../build/goods.js'

describe('goods', () => {
function zx(script) {
Expand Down Expand Up @@ -174,44 +174,73 @@ describe('goods', () => {
)
})

describe('loadDotenv()', () => {
const env1 = tempfile(
'.env',
`FOO=BAR
BAR=FOO+`
)
const env2 = tempfile('.env.default', `BAR2=FOO2`)
describe('dotenv', () => {
test('parse()', () => {
assert.deepEqual(
dotenv.parse('ENV=v1\nENV2=v2\n\n\n ENV3 = v3 \nexport ENV4=v4'),
{
ENV: 'v1',
ENV2: 'v2',
ENV3: 'v3',
ENV4: 'v4',
}
)
assert.deepEqual(dotenv.parse(''), {})

// TBD: multiline
const multiline = `SIMPLE=xyz123
NON_INTERPOLATED='raw text without variable interpolation'
MULTILINE = """
long text here,
e.g. a private SSH key
"""`
})

after(() => {
fs.remove(env1)
fs.remove(env2)
describe('load()', () => {
const file1 = tempfile('.env.1', 'ENV1=value1\nENV2=value2')
const file2 = tempfile('.env.2', 'ENV2=value222\nENV3=value3')
after(() => Promise.all([fs.remove(file1), fs.remove(file2)]))

test('loads env from files', () => {
const env = dotenv.load(file1, file2)
assert.equal(env.ENV1, 'value1')
assert.equal(env.ENV2, 'value2')
assert.equal(env.ENV3, 'value3')
})

test('throws error on ENOENT', () => {
try {
dotenv.load('./.env')
assert.throw()
} catch (e) {
assert.equal(e.code, 'ENOENT')
assert.equal(e.errno, -2)
}
})
})

test('handles multiple dotenv files', async () => {
const env = loadDotenv(env1, env2)
describe('loadSafe()', () => {
const file1 = tempfile('.env.1', 'ENV1=value1\nENV2=value2')
const file2 = '.env.notexists'

assert.equal((await $({ env })`echo $FOO`).stdout, 'BAR\n')
assert.equal((await $({ env })`echo $BAR`).stdout, 'FOO+\n')
assert.equal((await $({ env })`echo $BAR2`).stdout, 'FOO2\n')
})
after(() => fs.remove(file1))

test('handles replace evn', async () => {
const env = loadDotenv(env1)
$.env = env
assert.equal((await $`echo $FOO`).stdout, 'BAR\n')
assert.equal((await $`echo $BAR`).stdout, 'FOO+\n')
$.env = process.env
test('loads env from files', () => {
const env = dotenv.loadSafe(file1, file2)
assert.equal(env.ENV1, 'value1')
assert.equal(env.ENV2, 'value2')
})
})

test('handle error', async () => {
try {
loadDotenv('./.env')
describe('config()', () => {
test('updates process.env', () => {
const file1 = tempfile('.env.1', 'ENV1=value1')

assert.throw()
} catch (e) {
assert.equal(e.code, 'ENOENT')
assert.equal(e.errno, -2)
}
assert.equal(process.env.ENV1, undefined)
dotenv.config(file1)
assert.equal(process.env.ENV1, 'value1')
delete process.env.ENV1
})
})
})
})
43 changes: 0 additions & 43 deletions test/util.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ import {
tempfile,
preferLocalBin,
toCamelCase,
parseDotenv,
readEnvFromFile,
} from '../build/util.js'

describe('util', () => {
Expand Down Expand Up @@ -142,44 +140,3 @@ describe('util', () => {
assert.equal(toCamelCase('kebab-input-str'), 'kebabInputStr')
})
})

test('parseDotenv()', () => {
assert.deepEqual(
parseDotenv('ENV=v1\nENV2=v2\n\n\n ENV3 = v3 \nexport ENV4=v4'),
{
ENV: 'v1',
ENV2: 'v2',
ENV3: 'v3',
ENV4: 'v4',
}
)
assert.deepEqual(parseDotenv(''), {})

// TBD: multiline
const multiline = `SIMPLE=xyz123
NON_INTERPOLATED='raw text without variable interpolation'
MULTILINE = """
long text here,
e.g. a private SSH key
"""`
})

describe('readEnvFromFile()', () => {
const file = tempfile('.env', 'ENV=value1\nENV2=value24')
after(() => fsCore.remove(file))

test('handles correct proccess.env', () => {
const env = readEnvFromFile(file)
assert.equal(env.ENV, 'value1')
assert.equal(env.ENV2, 'value24')
assert.ok(env.NODE_VERSION !== '')
})

test('handles correct some env', () => {
const env = readEnvFromFile(file, { version: '1.0.0', name: 'zx' })
assert.equal(env.ENV, 'value1')
assert.equal(env.ENV2, 'value24')
assert.equal(env.version, '1.0.0')
assert.equal(env.name, 'zx')
})
})

0 comments on commit a95c63b

Please sign in to comment.