@dotenvx/dotenvx 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/package.json +3 -2
- package/src/cli/dotenvx.js +127 -12
- package/src/cli/helpers.js +120 -1
- package/src/lib/main.js +42 -7
- package/src/shared/logger.js +5 -1
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ More examples
|
|
|
94
94
|
```sh
|
|
95
95
|
FROM node:latest
|
|
96
96
|
RUN echo "HELLO=World" > .env && echo "console.log('Hello ' + process.env.HELLO)" > index.js
|
|
97
|
-
RUN curl -
|
|
97
|
+
RUN curl -fsS https://dotenvx.sh/ | sh
|
|
98
98
|
CMD ["dotenvx", "run", "--", "echo", "Hello $HELLO"]
|
|
99
99
|
```
|
|
100
100
|
|
|
@@ -157,7 +157,12 @@ $ dotenvx run --env-file=.env.local --env-file=.env -- node index.js
|
|
|
157
157
|
|
|
158
158
|
## Encrypt Your Env Files
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
```
|
|
161
|
+
dotenvx encrypt
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
> This will encrypt your `.env` file to a `.env.vault` file. Commit your `.env.vault` file safely to code.
|
|
165
|
+
> This will also generate a `.env.keys` file. Do NOT commit this file to code. Keep your `.env.keys` secret. 🤫
|
|
161
166
|
|
|
162
167
|
|
|
163
168
|
|
|
@@ -273,14 +278,12 @@ npm i @dotenvx/dotenvx --save
|
|
|
273
278
|
3. Or download it directly as a standalone binary:
|
|
274
279
|
|
|
275
280
|
```sh
|
|
276
|
-
curl -
|
|
281
|
+
curl -fsS https://dotenvx.sh/ | sh
|
|
277
282
|
```
|
|
278
283
|
|
|
279
|
-
Remove the `!` to install where you prefer. (the `!` downloads directly into `/usr/local/bin/`)
|
|
280
|
-
|
|
281
284
|
```sh
|
|
282
285
|
# download it to `./dotenvx`
|
|
283
|
-
curl -
|
|
286
|
+
curl -fsS https://dotenvx.sh/ | sh
|
|
284
287
|
|
|
285
288
|
# check it works
|
|
286
289
|
./dotenvx help
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.4.0",
|
|
3
3
|
"name": "@dotenvx/dotenvx",
|
|
4
4
|
"description": "a better dotenv–from the creator of `dotenv`",
|
|
5
5
|
"author": "@motdotla",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"commander": "^11.1.0",
|
|
28
28
|
"dotenv": "^16.3.1",
|
|
29
|
-
"winston": "^3.11.0"
|
|
29
|
+
"winston": "^3.11.0",
|
|
30
|
+
"xxhashjs": "^0.2.2"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"jest": "^29.7.0",
|
package/src/cli/dotenvx.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const { Command } = require('commander')
|
|
5
|
+
const dotenv = require('dotenv')
|
|
5
6
|
const program = new Command()
|
|
6
7
|
|
|
7
8
|
// constants
|
|
@@ -23,7 +24,7 @@ program
|
|
|
23
24
|
|
|
24
25
|
if (options.logLevel) {
|
|
25
26
|
logger.level = options.logLevel
|
|
26
|
-
logger.debug(`
|
|
27
|
+
logger.debug(`setting log level to ${options.logLevel}`)
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
// --quiet overides --log-level. only errors will be shown
|
|
@@ -48,14 +49,14 @@ program
|
|
|
48
49
|
.description(packageJson.description)
|
|
49
50
|
.version(packageJson.version)
|
|
50
51
|
|
|
52
|
+
// dotenvx run -- node index.js
|
|
51
53
|
program.command('run')
|
|
52
54
|
.description('inject env variables into your application process')
|
|
53
|
-
.option('-f, --env-file <paths...>', 'path to your env file', '.env')
|
|
55
|
+
.option('-f, --env-file <paths...>', 'path(s) to your env file(s)', '.env')
|
|
54
56
|
.option('-o, --overload', 'override existing env variables')
|
|
55
57
|
.action(function () {
|
|
56
|
-
// injecting 1 environment variable from ${options.envFile}
|
|
57
58
|
const options = this.opts()
|
|
58
|
-
logger.debug('
|
|
59
|
+
logger.debug('configuring options')
|
|
59
60
|
logger.debug(options)
|
|
60
61
|
|
|
61
62
|
// convert to array if needed
|
|
@@ -65,33 +66,39 @@ program.command('run')
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
const env = {}
|
|
69
|
+
const readableFilepaths = new Set()
|
|
70
|
+
const written = new Set()
|
|
68
71
|
|
|
69
72
|
for (const envFilepath of optionEnvFile) {
|
|
70
73
|
const filepath = helpers.resolvePath(envFilepath)
|
|
71
74
|
|
|
72
|
-
logger.verbose(`
|
|
75
|
+
logger.verbose(`injecting env from ${filepath}`)
|
|
73
76
|
|
|
74
77
|
try {
|
|
75
|
-
logger.debug(`
|
|
78
|
+
logger.debug(`reading env from ${filepath}`)
|
|
76
79
|
const src = fs.readFileSync(filepath, { encoding: ENCODING })
|
|
77
80
|
|
|
78
|
-
logger.debug(`
|
|
81
|
+
logger.debug(`parsing env from ${filepath}`)
|
|
79
82
|
const parsed = main.parse(src)
|
|
80
83
|
|
|
81
|
-
logger.debug(`
|
|
82
|
-
main.
|
|
84
|
+
logger.debug(`writing env from ${filepath}`)
|
|
85
|
+
const result = main.write(process.env, parsed, options.overload)
|
|
86
|
+
|
|
87
|
+
readableFilepaths.add(envFilepath)
|
|
88
|
+
result.written.forEach(key => written.add(key))
|
|
83
89
|
} catch (e) {
|
|
84
90
|
logger.warn(e)
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
if (readableFilepaths.size > 0) {
|
|
95
|
+
logger.info(`injecting ${written.size} environment ${helpers.pluralize('variable', written.size)} from ${[...readableFilepaths]}`)
|
|
96
|
+
}
|
|
89
97
|
|
|
90
98
|
// Extract command and arguments after '--'
|
|
91
99
|
const commandIndex = process.argv.indexOf('--')
|
|
92
100
|
if (commandIndex === -1 || commandIndex === process.argv.length - 1) {
|
|
93
|
-
logger.error('
|
|
94
|
-
logger.error('Exiting')
|
|
101
|
+
logger.error('at least one argument is required after the run command, received 0.')
|
|
95
102
|
process.exit(1)
|
|
96
103
|
} else {
|
|
97
104
|
const subCommand = process.argv.slice(commandIndex + 1)
|
|
@@ -100,4 +107,112 @@ program.command('run')
|
|
|
100
107
|
}
|
|
101
108
|
})
|
|
102
109
|
|
|
110
|
+
// dotenvx encrypt
|
|
111
|
+
program.command('encrypt')
|
|
112
|
+
.description('encrypt .env.* to .env.vault')
|
|
113
|
+
.option('-f, --env-file <paths...>', 'path(s) to your env file(s)', helpers.findEnvFiles('./'))
|
|
114
|
+
.action(function () {
|
|
115
|
+
const options = this.opts()
|
|
116
|
+
logger.debug('configuring options')
|
|
117
|
+
logger.debug(options)
|
|
118
|
+
|
|
119
|
+
let optionEnvFile = options.envFile
|
|
120
|
+
if (!Array.isArray(optionEnvFile)) {
|
|
121
|
+
optionEnvFile = [optionEnvFile]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
logger.verbose(`generating .env.keys from ${optionEnvFile}`)
|
|
126
|
+
|
|
127
|
+
const dotenvKeys = (dotenv.configDotenv({ path: '.env.keys' }).parsed || {})
|
|
128
|
+
|
|
129
|
+
for (const envFilepath of optionEnvFile) {
|
|
130
|
+
const filepath = helpers.resolvePath(envFilepath)
|
|
131
|
+
if (!fs.existsSync(filepath)) {
|
|
132
|
+
throw new Error(`file does not exist: ${filepath}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const environment = helpers.guessEnvironment(filepath)
|
|
136
|
+
const key = `DOTENV_KEY_${environment.toUpperCase()}`
|
|
137
|
+
|
|
138
|
+
let value = dotenvKeys[key]
|
|
139
|
+
|
|
140
|
+
// first time seeing new DOTENV_KEY_${environment}
|
|
141
|
+
if (!value || value.length === 0) {
|
|
142
|
+
logger.verbose(`generating ${key}`)
|
|
143
|
+
value = helpers.generateDotenvKey(environment)
|
|
144
|
+
logger.debug(`generating ${key} as ${value}`)
|
|
145
|
+
|
|
146
|
+
dotenvKeys[key] = value
|
|
147
|
+
} else {
|
|
148
|
+
logger.verbose(`existing ${key}`)
|
|
149
|
+
logger.debug(`existing ${key} as ${value}`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let keysData = `#/!!!!!!!!!!!!!!!!!!!.env.keys!!!!!!!!!!!!!!!!!!!!!!/
|
|
154
|
+
#/ DOTENV_KEYs. DO NOT commit to source control /
|
|
155
|
+
#/ [how it works](https://dotenv.org/env-keys) /
|
|
156
|
+
#/--------------------------------------------------/\n`
|
|
157
|
+
|
|
158
|
+
for (const key in dotenvKeys) {
|
|
159
|
+
const value = dotenvKeys[key]
|
|
160
|
+
keysData += `${key}="${value}"\n`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fs.writeFileSync('.env.keys', keysData)
|
|
164
|
+
} catch (e) {
|
|
165
|
+
logger.error(e)
|
|
166
|
+
process.exit(1)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
logger.verbose(`generating .env.vault from ${optionEnvFile}`)
|
|
171
|
+
|
|
172
|
+
const dotenvKeys = (dotenv.configDotenv({ path: '.env.keys' }).parsed || {})
|
|
173
|
+
const dotenvVaults = (dotenv.configDotenv({ path: '.env.vault' }).parsed || {})
|
|
174
|
+
|
|
175
|
+
for (const envFilepath of optionEnvFile) {
|
|
176
|
+
const filepath = helpers.resolvePath(envFilepath)
|
|
177
|
+
const environment = helpers.guessEnvironment(filepath)
|
|
178
|
+
const vault = `DOTENV_VAULT_${environment.toUpperCase()}`
|
|
179
|
+
|
|
180
|
+
let ciphertext = dotenvVaults[vault]
|
|
181
|
+
const dotenvKey = dotenvKeys[`DOTENV_KEY_${environment.toUpperCase()}`]
|
|
182
|
+
|
|
183
|
+
if (!ciphertext || ciphertext.length === 0 || helpers.changed(ciphertext, dotenvKey, filepath, ENCODING)) {
|
|
184
|
+
logger.verbose(`encrypting ${vault}`)
|
|
185
|
+
ciphertext = helpers.encryptFile(filepath, dotenvKey, ENCODING)
|
|
186
|
+
logger.verbose(`encrypting ${vault} as ${ciphertext}`)
|
|
187
|
+
|
|
188
|
+
dotenvVaults[vault] = ciphertext
|
|
189
|
+
} else {
|
|
190
|
+
logger.verbose(`existing ${vault}`)
|
|
191
|
+
logger.debug(`existing ${vault} as ${ciphertext}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let vaultData = `#/-------------------.env.vault---------------------/
|
|
196
|
+
#/ cloud-agnostic vaulting standard /
|
|
197
|
+
#/ [how it works](https://dotenv.org/env-vault) /
|
|
198
|
+
#/--------------------------------------------------/\n\n`
|
|
199
|
+
|
|
200
|
+
for (const vault in dotenvVaults) {
|
|
201
|
+
const value = dotenvVaults[vault]
|
|
202
|
+
const environment = vault.replace('DOTENV_VAULT_', '').toLowerCase()
|
|
203
|
+
vaultData += `# ${environment}\n`
|
|
204
|
+
vaultData += `${vault}="${value}"\n\n`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
fs.writeFileSync('.env.vault', vaultData)
|
|
208
|
+
} catch (e) {
|
|
209
|
+
logger.error(e)
|
|
210
|
+
process.exit(1)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
logger.info(`encrypted ${optionEnvFile} to .env.vault`)
|
|
214
|
+
|
|
215
|
+
// logger.info(`encrypting`)
|
|
216
|
+
})
|
|
217
|
+
|
|
103
218
|
program.parse(process.argv)
|
package/src/cli/helpers.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
1
2
|
const path = require('path')
|
|
3
|
+
const crypto = require('crypto')
|
|
2
4
|
const { spawn } = require('child_process')
|
|
5
|
+
const xxhash = require('xxhashjs')
|
|
6
|
+
const XXHASH_SEED = 0xABCD
|
|
7
|
+
|
|
8
|
+
const main = require('./../lib/main')
|
|
9
|
+
|
|
10
|
+
const RESERVED_ENV_FILES = ['.env.vault', '.env.projects', '.env.keys', '.env.me', '.env.x']
|
|
3
11
|
|
|
4
12
|
// resolve path based on current running process location
|
|
5
13
|
const resolvePath = function (filepath) {
|
|
@@ -22,7 +30,118 @@ const executeCommand = function (subCommand, env) {
|
|
|
22
30
|
})
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
const pluralize = function (word, count) {
|
|
34
|
+
// simple pluralization: add 's' at the end
|
|
35
|
+
if (count === 0 || count > 1) {
|
|
36
|
+
return word + 's'
|
|
37
|
+
} else {
|
|
38
|
+
return word
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const findEnvFiles = function (directory) {
|
|
43
|
+
const files = fs.readdirSync(directory)
|
|
44
|
+
|
|
45
|
+
const envFiles = files.filter(file =>
|
|
46
|
+
file.startsWith('.env') &&
|
|
47
|
+
!file.endsWith('.previous') &&
|
|
48
|
+
!RESERVED_ENV_FILES.includes(file)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return envFiles
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const guessEnvironment = function (file) {
|
|
55
|
+
const splitFile = file.split('.')
|
|
56
|
+
const possibleEnvironment = splitFile[2] // ['', 'env', environment']
|
|
57
|
+
|
|
58
|
+
if (!possibleEnvironment || possibleEnvironment.length === 0) {
|
|
59
|
+
return 'development'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return possibleEnvironment
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const generateDotenvKey = function (environment) {
|
|
66
|
+
const rand = crypto.randomBytes(32).toString('hex')
|
|
67
|
+
|
|
68
|
+
return `dotenv://:key_${rand}@dotenvx.com/vault/.env.vault?environment=${environment.toLowerCase()}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const encryptFile = function (filepath, dotenvKey, encoding) {
|
|
72
|
+
const key = this._parseEncryptionKeyFromDotenvKey(dotenvKey)
|
|
73
|
+
const message = fs.readFileSync(filepath, encoding)
|
|
74
|
+
|
|
75
|
+
const ciphertext = this.encrypt(key, message)
|
|
76
|
+
|
|
77
|
+
return ciphertext
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const encrypt = function (key, message) {
|
|
81
|
+
// set up nonce
|
|
82
|
+
const nonce = this._generateNonce()
|
|
83
|
+
|
|
84
|
+
// set up cipher
|
|
85
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)
|
|
86
|
+
|
|
87
|
+
// generate ciphertext
|
|
88
|
+
let ciphertext = ''
|
|
89
|
+
ciphertext += cipher.update(message, 'utf8', 'hex')
|
|
90
|
+
ciphertext += cipher.final('hex')
|
|
91
|
+
ciphertext += cipher.getAuthTag().toString('hex')
|
|
92
|
+
|
|
93
|
+
// prepend nonce
|
|
94
|
+
ciphertext = nonce.toString('hex') + ciphertext
|
|
95
|
+
|
|
96
|
+
// base64 encode output
|
|
97
|
+
return Buffer.from(ciphertext, 'hex').toString('base64')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const changed = function (ciphertext, dotenvKey, filepath, encoding) {
|
|
101
|
+
const key = this._parseEncryptionKeyFromDotenvKey(dotenvKey)
|
|
102
|
+
const decrypted = main.decrypt(ciphertext, key)
|
|
103
|
+
const raw = fs.readFileSync(filepath, encoding)
|
|
104
|
+
|
|
105
|
+
return this.hash(decrypted) !== this.hash(raw)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const hash = function (str) {
|
|
109
|
+
return xxhash.h32(str, XXHASH_SEED).toString(16)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const _parseEncryptionKeyFromDotenvKey = function (dotenvKey) {
|
|
113
|
+
// Parse DOTENV_KEY. Format is a URI
|
|
114
|
+
const uri = new URL(dotenvKey)
|
|
115
|
+
|
|
116
|
+
// Get decrypt key
|
|
117
|
+
const key = uri.password
|
|
118
|
+
if (!key) {
|
|
119
|
+
throw new Error('INVALID_DOTENV_KEY: Missing key part')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return Buffer.from(key.slice(-64), 'hex')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const _generateNonce = function () {
|
|
126
|
+
return crypto.randomBytes(this._nonceBytes())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const _nonceBytes = function () {
|
|
130
|
+
return 12
|
|
131
|
+
}
|
|
132
|
+
|
|
25
133
|
module.exports = {
|
|
26
134
|
resolvePath,
|
|
27
|
-
executeCommand
|
|
135
|
+
executeCommand,
|
|
136
|
+
pluralize,
|
|
137
|
+
findEnvFiles,
|
|
138
|
+
guessEnvironment,
|
|
139
|
+
generateDotenvKey,
|
|
140
|
+
encryptFile,
|
|
141
|
+
encrypt,
|
|
142
|
+
changed,
|
|
143
|
+
hash,
|
|
144
|
+
_parseEncryptionKeyFromDotenvKey,
|
|
145
|
+
_generateNonce,
|
|
146
|
+
_nonceBytes
|
|
28
147
|
}
|
package/src/lib/main.js
CHANGED
|
@@ -5,6 +5,10 @@ const config = function (options) {
|
|
|
5
5
|
return dotenv.config(options)
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
const decrypt = function (encrypted, keyStr) {
|
|
9
|
+
return dotenv.decrypt(encrypted, keyStr)
|
|
10
|
+
}
|
|
11
|
+
|
|
8
12
|
const parse = function (src) {
|
|
9
13
|
const result = dotenv.parse(src)
|
|
10
14
|
|
|
@@ -13,16 +17,47 @@ const parse = function (src) {
|
|
|
13
17
|
return result
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const write = function (processEnv = {}, parsed = {}, overload = false) {
|
|
21
|
+
if (typeof parsed !== 'object') {
|
|
22
|
+
throw new Error('OBJECT_REQUIRED: Please check the parsed argument being passed to write')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const written = new Set()
|
|
26
|
+
const preExisting = new Set()
|
|
27
|
+
|
|
28
|
+
// set processEnv
|
|
29
|
+
for (const key of Object.keys(parsed)) {
|
|
30
|
+
if (Object.prototype.hasOwnProperty.call(processEnv, key)) {
|
|
31
|
+
if (overload === true) {
|
|
32
|
+
processEnv[key] = parsed[key]
|
|
33
|
+
written.add(key)
|
|
34
|
+
|
|
35
|
+
logger.verbose(`${key} set`)
|
|
36
|
+
logger.debug(`${key} set to ${parsed[key]}`)
|
|
37
|
+
} else {
|
|
38
|
+
preExisting.add(key)
|
|
39
|
+
|
|
40
|
+
logger.verbose(`${key} pre-exists`)
|
|
41
|
+
logger.debug(`${key} pre-exists as ${processEnv[key]}`)
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
processEnv[key] = parsed[key]
|
|
45
|
+
written.add(key)
|
|
46
|
+
|
|
47
|
+
logger.verbose(`${key} set`)
|
|
48
|
+
logger.debug(`${key} set to ${parsed[key]}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
written,
|
|
54
|
+
preExisting
|
|
55
|
+
}
|
|
22
56
|
}
|
|
23
57
|
|
|
24
58
|
module.exports = {
|
|
25
59
|
config,
|
|
60
|
+
decrypt,
|
|
26
61
|
parse,
|
|
27
|
-
|
|
62
|
+
write
|
|
28
63
|
}
|
package/src/shared/logger.js
CHANGED
|
@@ -7,10 +7,14 @@ const transports = winston.transports
|
|
|
7
7
|
|
|
8
8
|
const packageJson = require('./packageJson')
|
|
9
9
|
|
|
10
|
+
function pad (word) {
|
|
11
|
+
return word.padEnd(9, ' ')
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
const dotenvxFormat = printf(({ level, message, label, timestamp }) => {
|
|
11
15
|
const formattedMessage = typeof message === 'object' ? JSON.stringify(message) : message
|
|
12
16
|
|
|
13
|
-
return `[dotenvx@${packageJson.version}][${level.toUpperCase()}] ${formattedMessage}`
|
|
17
|
+
return `[dotenvx@${packageJson.version}]${pad(`[${level.toUpperCase()}]`)} ${formattedMessage}`
|
|
14
18
|
})
|
|
15
19
|
|
|
16
20
|
const logger = createLogger({
|