Commit f1ca807

Anton Golub <antongolub@antongolub.com>
2024-12-28 09:47:38
refactor: assemble dotenv utils (#1043)
* refactor: assemble dotenv utils continues #1034 * chore: prettier
1 parent edef66e
.github/workflows/codeql.yml
@@ -1,10 +1,10 @@
-name: "CodeQL Advanced"
+name: 'CodeQL Advanced'
 
 on:
   push:
-    branches: [ "main" ]
+    branches: ['main']
   pull_request:
-    branches: [ "main" ]
+    branches: ['main']
   schedule:
     - cron: '28 6 * * 3'
 
@@ -28,29 +28,29 @@ jobs:
       fail-fast: false
       matrix:
         include:
-        - language: javascript-typescript
-          build-mode: none
+          - language: javascript-typescript
+            build-mode: none
     steps:
-    - name: Checkout repository
-      uses: actions/checkout@v4
-
-    - name: Initialize CodeQL
-      uses: github/codeql-action/init@v3
-      with:
-        languages: ${{ matrix.language }}
-        build-mode: ${{ matrix.build-mode }}
-      
-    - if: matrix.build-mode == 'manual'
-      shell: bash
-      run: |
-        echo 'If you are using a "manual" build mode for one or more of the' \
-          'languages you are analyzing, replace this with the commands to build' \
-          'your code, for example:'
-        echo '  make bootstrap'
-        echo '  make release'
-        exit 1
-
-    - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v3
-      with:
-        category: "/language:${{matrix.language}}"
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Initialize CodeQL
+        uses: github/codeql-action/init@v3
+        with:
+          languages: ${{ matrix.language }}
+          build-mode: ${{ matrix.build-mode }}
+
+      - if: matrix.build-mode == 'manual'
+        shell: bash
+        run: |
+          echo 'If you are using a "manual" build mode for one or more of the' \
+            'languages you are analyzing, replace this with the commands to build' \
+            'your code, for example:'
+          echo '  make bootstrap'
+          echo '  make release'
+          exit 1
+
+      - name: Perform CodeQL Analysis
+        uses: github/codeql-action/analyze@v3
+        with:
+          category: '/language:${{matrix.language}}'
docs/v7/api.md
@@ -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)
-```
\ No newline at end of file
docs/api.md
@@ -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
+```
src/cli.ts
@@ -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'
@@ -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
src/goods.ts
@@ -14,6 +14,7 @@
 
 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,
@@ -21,10 +22,10 @@ import {
   isStringLiteral,
   parseBool,
   parseDuration,
-  readEnvFromFile,
   toCamelCase,
 } from './util.js'
 import {
+  fs,
   minimist,
   nodeFetch,
   type RequestInfo,
@@ -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,
+  }
+})()
src/util.ts
@@ -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),
-  }
-}
test/export.test.js
@@ -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')
@@ -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')
test/goods.test.js
@@ -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) {
@@ -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
+      })
     })
   })
 })
test/util.test.js
@@ -30,8 +30,6 @@ import {
   tempfile,
   preferLocalBin,
   toCamelCase,
-  parseDotenv,
-  readEnvFromFile,
 } from '../build/util.js'
 
 describe('util', () => {
@@ -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')
-  })
-})