onesecret 0.0.0 → 0.1.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/bin/help.txt CHANGED
@@ -4,34 +4,32 @@ Usage
4
4
  Commands
5
5
  init Write config/default.enc.yml for a new age key it prints once,
6
6
  or for SOPS_AGE_KEY when exported
7
- set NAME=value... Encrypt one or more secrets into a layer
7
+ set NAME Encrypt a secret into a layer, typed in $EDITOR
8
+ (or read from stdin when piped)
8
9
  unset NAME... Remove secrets from a layer
9
10
  edit [file] Open an encrypted file decrypted in $EDITOR, encrypt it on save
10
11
  (a plain .yml or .json file opens as it is)
11
12
  rekey Encrypt a layer again with a new data key, for its recipients or a .sops.yaml rule
12
13
  ls List secret names (no key needed)
13
14
  get NAME Print one value, from a plain or an encrypted layer
15
+ (with no trailing newline when piped)
14
16
  dotenv Print every decrypted secret as NAME=value
17
+ (--json prints them as one JSON object)
15
18
  exec -- <command> Run a command with the config and secrets in its environment
16
19
  (--env also sets its NODE_ENV)
17
- kubernetes --name <name> Apply the secrets as a Kubernetes Secret, print its checksum
18
- fly --app <app> Import the secrets into a Fly app, print their checksum
19
20
 
20
21
  Options
21
22
  --env, -e Layer to read on top of "default" (defaults to NODE_ENV, then "development").
22
23
  Write commands (set, unset, edit, rekey) write this layer, "default" when omitted.
23
- kubernetes and fly require it, and the layer file must exist ("default" deploys the base alone).
24
+ Passed to dotenv or ls, the layer file must exist.
24
25
  --dir Directory holding the config files (defaults to "config")
25
26
  --cwd Project root, where package.json and the config directory are (defaults to the current directory)
26
- --namespace, -n Kubernetes namespace
27
- --context kubectl context
28
- --no-stage fly: apply the secrets now instead of on the next deploy
27
+ --json dotenv: print a JSON object instead of NAME=value lines
29
28
 
30
29
  Examples
31
30
  $ onesecret init
32
- $ onesecret set DATABASE_URL=postgres://localhost/app
33
- $ onesecret set --env production DATABASE_URL=postgres://db.internal/app
31
+ $ onesecret set DATABASE_URL
32
+ $ printf %s "$TOKEN" | onesecret set --env production API_TOKEN
34
33
  $ onesecret edit config/production.enc.yml
35
34
  $ onesecret exec -- node server.js
36
- $ onesecret kubernetes --env production --name api-env --namespace api
37
- $ onesecret fly --env production --app my-app
35
+ $ onesecret dotenv --env production --json
package/bin/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- const { run } = require('../src/cli')
4
+ const { render, run } = require('../src/cli')
5
5
 
6
6
  run(process.argv.slice(2))
7
- .then(({ stdout, exitCode = 0 }) => {
8
- if (stdout) console.log(stdout)
9
- process.exitCode = exitCode
7
+ .then(result => {
8
+ process.stdout.write(render(result, process.stdout.isTTY))
9
+ process.exitCode = result.exitCode ?? 0
10
10
  })
11
11
  .catch(error => {
12
12
  console.error(error.code ? error.message : error)
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "onesecret",
3
3
  "description": "Keep just one secret to decrypt the rest: SOPS + age secrets for Node.js, with no binaries to install.",
4
- "homepage": "https://github.com/kikobeats/onesecret",
5
- "version": "0.0.0",
4
+ "homepage": "https://onesecret.js.org/",
5
+ "version": "0.1.0",
6
6
  "main": "src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js"
@@ -15,6 +15,12 @@
15
15
  "name": "Jose Francisco 'Kiko' Verdú Gambín",
16
16
  "url": "https://kikobeats.com"
17
17
  },
18
+ "contributors": [
19
+ {
20
+ "name": "Jose Francisco 'Kiko' Verdú Gambín",
21
+ "email": "kikohumanbeatbox@gmail.com"
22
+ }
23
+ ],
18
24
  "repository": {
19
25
  "type": "git",
20
26
  "url": "git+https://github.com/kikobeats/onesecret.git"
@@ -41,12 +47,25 @@
41
47
  "@commitlint/cli": "latest",
42
48
  "@commitlint/config-conventional": "latest",
43
49
  "ava": "latest",
50
+ "browser-sync": "latest",
44
51
  "c8": "latest",
52
+ "ci-publish": "latest",
53
+ "concurrently": "latest",
54
+ "conventional-changelog": "latest",
55
+ "conventional-changelog-conventionalcommits": "latest",
56
+ "cssnano": "latest",
57
+ "cssnano-preset-advanced": "latest",
45
58
  "dotenv": "~18.0.8",
46
59
  "finepack": "latest",
47
60
  "git-authors-cli": "latest",
48
61
  "github-generate-release": "latest",
62
+ "gulp": "latest",
63
+ "gulp-concat": "latest",
64
+ "gulp-postcss": "latest",
65
+ "gulp-uglify": "latest",
49
66
  "nano-staged": "latest",
67
+ "postcss": "latest",
68
+ "postcss-focus": "latest",
50
69
  "simple-git-hooks": "latest",
51
70
  "standard": "latest",
52
71
  "standard-markdown": "latest",
@@ -94,16 +113,28 @@
94
113
  "commit-msg": "npx commitlint --edit",
95
114
  "pre-commit": "npx nano-staged"
96
115
  },
116
+ "standard": {
117
+ "ignore": [
118
+ "docs/cover/shaders.js"
119
+ ]
120
+ },
97
121
  "scripts": {
122
+ "build": "gulp build",
98
123
  "clean": "rm -rf node_modules",
99
- "contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true",
124
+ "contributors": "(git-authors-cli && finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true",
100
125
  "coverage": "c8 report --reporter=text-lcov > coverage/lcov.info",
126
+ "dev": "concurrently \"gulp\" \"pnpm run dev:server\"",
127
+ "dev:server": "browser-sync start --server --files \"index.html, README.md, static/**/*.(css|js)\"",
101
128
  "lint": "standard-markdown README.md && standard",
102
- "postrelease": "pnpm release:tags && pnpm release:github && pnpm publish",
129
+ "postrelease": "pnpm release:tags && pnpm release:github && (ci-publish || pnpm publish --access=public)",
103
130
  "pretest": "pnpm lint",
104
- "release": "standard-version -a",
131
+ "release": "pnpm release:version && pnpm release:changelog && pnpm release:commit && pnpm release:tag",
132
+ "release:changelog": "conventional-changelog -p conventionalcommits -i CHANGELOG.md",
133
+ "release:commit": "git add package.json CHANGELOG.md && git commit -m \"chore(release): $(node -p \"require('./package.json').version\")\"",
105
134
  "release:github": "github-generate-release",
106
- "release:tags": "git push --follow-tags origin HEAD:master",
135
+ "release:tag": "git tag -a v$(node -p \"require('./package.json').version\") -m \"v$(node -p \"require('./package.json').version\")\"",
136
+ "release:tags": "git push origin HEAD:master --follow-tags",
137
+ "release:version": "standard-version --skip.changelog --skip.commit --skip.tag",
107
138
  "test": "c8 ava"
108
139
  }
109
140
  }
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+
3
+ const { OnesecretError } = require('./error')
4
+
5
+ const ACCESSORS = ['get', 'has', 'require', 'required']
6
+
7
+ const isWalkable = value => value !== null && typeof value === 'object'
8
+
9
+ const isValue = (object, key) =>
10
+ isWalkable(object) && Object.prototype.propertyIsEnumerable.call(object, key)
11
+
12
+ const segmentsOf = path => {
13
+ if (Array.isArray(path)) return path.map(String)
14
+ if (typeof path === 'number') return [String(path)]
15
+ if (typeof path === 'string') return path.split('.')
16
+ throw new OnesecretError(
17
+ 'a config path is a string such as "database.url", or an array of keys',
18
+ 'EUSAGE'
19
+ )
20
+ }
21
+
22
+ const lookup = (values, path) => {
23
+ if (typeof path === 'string' && isValue(values, path)) return values[path]
24
+ let current = values
25
+ for (const key of segmentsOf(path)) {
26
+ if (!isValue(current, key)) return undefined
27
+ current = current[key]
28
+ }
29
+ return current
30
+ }
31
+
32
+ const assertNoReservedNames = values => {
33
+ const reserved = ACCESSORS.filter(name => Object.hasOwn(values, name))
34
+ if (reserved.length > 0) {
35
+ throw new OnesecretError(
36
+ `${reserved
37
+ .map(name => `"${name}"`)
38
+ .join(', ')} cannot be a config name, config() uses it to read values`,
39
+ 'EFORMAT'
40
+ )
41
+ }
42
+ }
43
+
44
+ const withAccessors = (values, env) => {
45
+ assertNoReservedNames(values)
46
+ const get = (path, fallback) => {
47
+ const value = lookup(values, path)
48
+ return value === undefined ? fallback : value
49
+ }
50
+ const has = path => lookup(values, path) !== undefined
51
+ const require = path => {
52
+ if (!has(path)) {
53
+ throw new OnesecretError(
54
+ `"${path}" is required but not set in the "${env}" config`,
55
+ 'EMISSING'
56
+ )
57
+ }
58
+ return get(path)
59
+ }
60
+ const required = new Proxy(values, {
61
+ get: (target, path) =>
62
+ typeof path === 'string' ? require(path) : undefined
63
+ })
64
+ for (const [name, value] of Object.entries({ get, has, require, required })) {
65
+ Object.defineProperty(values, name, { value, enumerable: false })
66
+ }
67
+ return values
68
+ }
69
+
70
+ module.exports = { ACCESSORS, withAccessors }
package/src/cli.js CHANGED
@@ -3,13 +3,13 @@
3
3
  const { spawn, spawnSync } = require('node:child_process')
4
4
  const fs = require('node:fs')
5
5
  const os = require('node:os')
6
+ const tty = require('node:tty')
6
7
  const path = require('node:path')
7
8
  const mri = require('mri')
8
9
  const secrets = require('./secrets')
9
10
  const write = require('./write')
10
- const deploy = require('./deploy')
11
11
  const store = require('./store')
12
- const { isEnvScalar, toDotenv } = require('./format')
12
+ const { isEnvScalar, toDotenv, toEnv } = require('./format')
13
13
  const yaml = require('./yaml')
14
14
  const { withoutAgeKey } = require('./keys')
15
15
  const { OnesecretError } = require('./error')
@@ -31,6 +31,15 @@ const readOptions = flags => ({
31
31
 
32
32
  const writeOptions = flags => ({ dir: flags.dir, cwd: flags.cwd })
33
33
 
34
+ const readLayersFor = (command, flags) => {
35
+ const layered = secrets.readLayers(readOptions(flags))
36
+ if (!flags.env) return layered
37
+ return secrets.requireEnvLayer(
38
+ layered,
39
+ `${command} found no "${layered.env}" layer in ${layered.dir}, check the --env name`
40
+ )
41
+ }
42
+
34
43
  const writeLayerOf = flags => flags.env || 'default'
35
44
 
36
45
  const relative = file => path.relative(process.cwd(), file) || file
@@ -191,8 +200,7 @@ const editInWorkspace = (target, copy) => {
191
200
  }
192
201
  }
193
202
 
194
- const edit = (flags, args) => {
195
- const target = editTargetOf(flags, args)
203
+ const withWorkspace = use => {
196
204
  const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'onesecret-'))
197
205
  const removeWorkspace = () =>
198
206
  fs.rmSync(workspace, { recursive: true, force: true })
@@ -202,18 +210,73 @@ const edit = (flags, args) => {
202
210
  }
203
211
  for (const signal of EDIT_SIGNALS) process.on(signal, onSignal)
204
212
  try {
205
- return editInWorkspace(target, path.join(workspace, target.workspaceName))
213
+ return use(workspace)
206
214
  } finally {
207
215
  removeWorkspace()
208
216
  for (const signal of EDIT_SIGNALS) process.off(signal, onSignal)
209
217
  }
210
218
  }
211
219
 
220
+ const edit = (flags, args) => {
221
+ const target = editTargetOf(flags, args)
222
+ return withWorkspace(workspace =>
223
+ editInWorkspace(target, path.join(workspace, target.workspaceName))
224
+ )
225
+ }
226
+
227
+ const SET_USAGE =
228
+ 'set takes one NAME: the value comes from $EDITOR, or from stdin when piped'
229
+
230
+ const EDITOR_NEWLINES = ['', '\n', '\r\n']
231
+ const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
232
+
233
+ const assertStorable = value => {
234
+ if (value.includes('\0')) {
235
+ throw new OnesecretError(
236
+ 'the value contains a NUL byte, which no environment variable can hold',
237
+ 'EVALUE'
238
+ )
239
+ }
240
+ return value
241
+ }
242
+
243
+ const valueFromEditor = (name, current = '') =>
244
+ withWorkspace(workspace => {
245
+ const file = path.join(workspace, name)
246
+ fs.writeFileSync(file, current, { mode: 0o600 })
247
+ const saved = openEditor(file)
248
+ if (EDITOR_NEWLINES.some(newline => saved === current + newline)) {
249
+ return current
250
+ }
251
+ const value = saved.replace(/\r?\n$/, '')
252
+ return value === '' ? null : assertStorable(value)
253
+ })
254
+
255
+ const valueFromStdin = () => {
256
+ let value
257
+ try {
258
+ value = STRICT_UTF8.decode(fs.readFileSync(0))
259
+ } catch (error) {
260
+ if (!(error instanceof TypeError)) throw error
261
+ throw new OnesecretError('the value on stdin is not valid UTF-8', 'EVALUE')
262
+ }
263
+ if (value === '') {
264
+ throw new OnesecretError(
265
+ 'no value on stdin, pipe the value or run set in a terminal to open $EDITOR',
266
+ 'EUSAGE'
267
+ )
268
+ }
269
+ return assertStorable(value)
270
+ }
271
+
272
+ const readSetValue = (name, current, { interactive = tty.isatty(0) } = {}) =>
273
+ interactive ? valueFromEditor(name, current) : valueFromStdin()
274
+
212
275
  const exec = (flags, command) => {
213
276
  if (command.length === 0) usage('exec needs a command after --')
214
277
  const env = withoutAgeKey(process.env)
215
278
  if (flags.env) env.NODE_ENV = flags.env
216
- secrets.fill(
279
+ secrets.writeEnv(
217
280
  { ...readOptions(flags), processEnv: env },
218
281
  { allowKeyCommand: true }
219
282
  )
@@ -254,17 +317,25 @@ const COMMANDS = {
254
317
  `private key ${result.identity}`,
255
318
  `secrets ${relative(result.defaultFile)}`,
256
319
  '',
257
- 'The private key is shown only now and is not saved anywhere. Keep it in your',
258
- 'password manager and as SOPS_AGE_KEY in CI. To use it in this shell:',
320
+ 'The private key is the only secret to keep: it decrypts all the others.',
321
+ 'It is shown only now and saved nowhere, so store it safely (and as',
322
+ 'SOPS_AGE_KEY in CI). To use it in this shell:',
259
323
  '',
260
324
  ` export SOPS_AGE_KEY=${result.identity}`
261
325
  ].join('\n')
262
326
  },
263
327
  set: (flags, args) => {
264
- if (args.length === 0) usage('set needs at least one NAME=value')
265
- return `${relative(
266
- write.set(writeOptions(flags), writeLayerOf(flags), args)
267
- )} encrypted`
328
+ if (args.length !== 1 || args[0].includes('=')) usage(SET_USAGE)
329
+ const [name] = args
330
+ const { file, status } = write.set(
331
+ writeOptions(flags),
332
+ writeLayerOf(flags),
333
+ name,
334
+ current => readSetValue(name, current)
335
+ )
336
+ if (status === 'cancelled') return `${name} not set, the value was empty`
337
+ if (status === 'unchanged') return `${name} unchanged in ${relative(file)}`
338
+ return `${relative(file)} encrypted`
268
339
  },
269
340
  unset: (flags, args) => {
270
341
  if (args.length === 0) usage('unset needs at least one NAME')
@@ -277,7 +348,7 @@ const COMMANDS = {
277
348
  `${relative(
278
349
  write.rekey(writeOptions(flags), writeLayerOf(flags))
279
350
  )} encrypted with a new data key`,
280
- ls: flags => secrets.names(readOptions(flags)).join('\n'),
351
+ ls: flags => secrets.namesOf(readLayersFor('ls', flags)).join('\n'),
281
352
  get: (flags, args) => {
282
353
  if (args.length !== 1) usage('get needs exactly one NAME')
283
354
  const values = secrets.get(readOptions(flags), args[0])
@@ -285,33 +356,19 @@ const COMMANDS = {
285
356
  throw new OnesecretError(`"${args[0]}" is not set in any layer`, 'EVALUE')
286
357
  }
287
358
  const value = values[args[0]]
288
- if (value === null) return ''
289
- return isEnvScalar(value) ? String(value) : yaml.dump(value).trimEnd()
359
+ if (value === null) return { stdout: '', exact: true }
360
+ return {
361
+ stdout: isEnvScalar(value) ? String(value) : yaml.dump(value).trimEnd(),
362
+ exact: true
363
+ }
290
364
  },
291
- dotenv: flags =>
292
- toDotenv(
293
- secrets.loadSecrets(secrets.readLayers(readOptions(flags)))
294
- ).trimEnd(),
295
- exec: (flags, args) => exec(flags, args),
296
- kubernetes: flags => {
297
- const { checksum, output } = deploy.kubernetes({
298
- ...readOptions(flags),
299
- name: flags.name,
300
- namespace: flags.namespace,
301
- context: flags.context
302
- })
303
- if (output) console.error(output)
304
- return checksum
365
+ dotenv: flags => {
366
+ const loaded = secrets.loadSecrets(readLayersFor('dotenv', flags))
367
+ return flags.json
368
+ ? JSON.stringify(toEnv(loaded))
369
+ : toDotenv(loaded).trimEnd()
305
370
  },
306
- fly: flags => {
307
- const { checksum, output } = deploy.fly({
308
- ...readOptions(flags),
309
- app: flags.app,
310
- stage: flags.stage
311
- })
312
- if (output) console.error(output)
313
- return checksum
314
- }
371
+ exec: (flags, args) => exec(flags, args)
315
372
  }
316
373
 
317
374
  const parse = argv => {
@@ -322,10 +379,9 @@ const parse = argv => {
322
379
  _: [command, ...args],
323
380
  ...flags
324
381
  } = mri(own, {
325
- boolean: ['help', 'version', 'stage'],
326
- string: ['dir', 'env', 'cwd', 'name', 'namespace', 'context', 'app'],
327
- alias: { h: 'help', v: 'version', e: 'env', n: 'namespace' },
328
- default: { stage: true }
382
+ boolean: ['help', 'version', 'json'],
383
+ string: ['dir', 'env', 'cwd'],
384
+ alias: { h: 'help', v: 'version', e: 'env' }
329
385
  })
330
386
  return {
331
387
  command,
@@ -340,8 +396,14 @@ const run = async argv => {
340
396
  if (flags.help || !command) return { stdout: HELP.trimEnd() }
341
397
  const handler = COMMANDS[command]
342
398
  if (!handler) usage(`unknown command "${command}"`)
399
+ if (flags.json && command !== 'dotenv') usage('--json only works with dotenv')
343
400
  const result = await handler(flags, args)
344
401
  return typeof result === 'object' ? result : { stdout: result }
345
402
  }
346
403
 
347
- module.exports = { run }
404
+ const render = ({ stdout, exact = false }, isTTY) => {
405
+ if (exact && !isTTY) return stdout
406
+ return stdout ? `${stdout}\n` : ''
407
+ }
408
+
409
+ module.exports = { readSetValue, render, run }
package/src/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { config, load, names } = require('./secrets')
4
- const { fly, kubernetes } = require('./deploy')
3
+ const { config, env } = require('./secrets')
5
4
  const { checksum, toDotenv, toEnv } = require('./format')
6
5
  const { withoutAgeKey } = require('./keys')
7
6
  const { OnesecretError } = require('./error')
@@ -9,10 +8,7 @@ const { OnesecretError } = require('./error')
9
8
  module.exports = {
10
9
  checksum,
11
10
  config,
12
- fly,
13
- kubernetes,
14
- load,
15
- names,
11
+ env,
16
12
  toDotenv,
17
13
  toEnv,
18
14
  withoutAgeKey,
package/src/secrets.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const crypto = require('node:crypto')
3
4
  const fs = require('node:fs')
4
5
  const path = require('node:path')
5
6
  const sops = require('./sops')
@@ -7,6 +8,7 @@ const store = require('./store')
7
8
  const { withIdentities } = require('./keys')
8
9
  const { isEnvName, isEnvScalar, toEnvValue } = require('./format')
9
10
  const { OnesecretError } = require('./error')
11
+ const { withAccessors } = require('./accessors')
10
12
 
11
13
  const DEFAULT_DIR = 'config'
12
14
  const DEFAULT_ENV = 'development'
@@ -65,19 +67,21 @@ const readLayer = (dir, name) => {
65
67
  plainFile,
66
68
  encryptedFile,
67
69
  values: plainFile ? store.readPlain(plainFile) : {},
68
- document: encryptedFile ? readEncrypted(encryptedFile) : null
70
+ ...(encryptedFile ? readEncrypted(encryptedFile) : { document: null })
69
71
  }
70
72
  }
71
73
 
74
+ const sha256 = data => crypto.createHash('sha256').update(data).digest('hex')
75
+
72
76
  const readEncrypted = file => {
73
- const document = store.readDocument(file)
74
- if (document === null) return null
77
+ const { text, document } = store.readDocumentText(file)
78
+ if (document === null) return { document: null }
75
79
  try {
76
80
  sops.names(document)
77
81
  } catch (error) {
78
82
  throw new OnesecretError(`${file}: ${error.reason}`, error.code)
79
83
  }
80
- return document
84
+ return { document, contentHash: sha256(text) }
81
85
  }
82
86
 
83
87
  const readLayers = input => {
@@ -128,6 +132,39 @@ const decryptDocuments = (documents, keyCommand, keyOptions) => {
128
132
  const decryptDocument = (document, keyCommand) =>
129
133
  decryptDocuments([document], keyCommand)[0]
130
134
 
135
+ const decryptedByFile = new Map()
136
+
137
+ const fingerprintOf = identities =>
138
+ sha256(
139
+ identities
140
+ .map(identity => Buffer.from(identity).toString('hex'))
141
+ .sort()
142
+ .join('\n')
143
+ )
144
+
145
+ const decryptOnce = (layers, keyCommand, keyOptions) => {
146
+ const encrypted = layers.filter(({ document }) => document)
147
+ if (encrypted.length === 0) return []
148
+ return withIdentities(
149
+ keyCommand,
150
+ identities => {
151
+ const fingerprint = fingerprintOf(identities)
152
+ return encrypted.map(({ encryptedFile, contentHash, document }) => {
153
+ const key = `${fingerprint}:${contentHash}`
154
+ const cached = decryptedByFile.get(encryptedFile)
155
+ if (cached?.key !== key) {
156
+ decryptedByFile.set(encryptedFile, {
157
+ key,
158
+ value: sops.decrypt(document, identities)
159
+ })
160
+ }
161
+ return structuredClone(decryptedByFile.get(encryptedFile).value)
162
+ })
163
+ },
164
+ keyOptions
165
+ )
166
+ }
167
+
131
168
  const UNSAFE_NAME = '__proto__'
132
169
 
133
170
  const assign = (target, name, value) =>
@@ -177,12 +214,18 @@ const pick = (values, names) => {
177
214
  return picked
178
215
  }
179
216
 
180
- const names = input => secretNamesOf(readLayers(input).layers)
217
+ const namesOf = layered => secretNamesOf(layered.layers)
181
218
 
182
- const loadLayers = ({ layers, keyCommand }) =>
183
- mergeLayers(layers, decryptDocuments(documentsOf(layers), keyCommand))
219
+ const requireEnvLayer = (layered, message) => {
220
+ const envLayer = layered.layers.find(({ name }) => name === layered.env)
221
+ if (!envLayer.plainFile && !envLayer.encryptedFile) {
222
+ throw new OnesecretError(message, 'EVALUE')
223
+ }
224
+ return layered
225
+ }
184
226
 
185
- const load = input => loadLayers(readLayers(input))
227
+ const loadLayers = ({ layers, keyCommand }) =>
228
+ mergeLayers(layers, decryptOnce(layers, keyCommand))
186
229
 
187
230
  const get = (input, name) => {
188
231
  const layered = readLayers(input)
@@ -212,9 +255,7 @@ const decryptForConfig = (
212
255
  allowKeyCommand = !DEPLOYED_ENVS.includes(env)
213
256
  ) => {
214
257
  try {
215
- return decryptDocuments(documentsOf(layers), keyCommand, {
216
- allowKeyCommand
217
- })
258
+ return decryptOnce(layers, keyCommand, { allowKeyCommand })
218
259
  } catch (error) {
219
260
  if (!KEY_ERRORS.includes(error.code)) throw error
220
261
  const verb = missing.length === 1 ? 'is' : 'are'
@@ -278,15 +319,13 @@ const assertScalarSecrets = (secrets, missing) => {
278
319
  }
279
320
  }
280
321
 
281
- const config = input => fill(input)
282
-
283
- const fill = (input, { allowKeyCommand } = {}) => {
322
+ const resolve = (input, { allowKeyCommand } = {}) => {
284
323
  const options = optionsOf(input)
285
324
  const target = options.processEnv || process.env
286
325
  const skip = target[SKIP_VARIABLE] === '1'
287
326
  const layered = readLayers(options)
288
327
  if (!hasFiles(layered.layers)) {
289
- if (skip) return {}
328
+ if (skip) return { target, env: layered.env, result: {}, missing: {} }
290
329
  throw new OnesecretError(
291
330
  `no config files for "default" or "${layered.env}" found in ${layered.dir}`,
292
331
  'EMISSING'
@@ -301,6 +340,7 @@ const fill = (input, { allowKeyCommand } = {}) => {
301
340
  : null
302
341
  const merged = mergeLayers(layered.layers, decrypted)
303
342
  const result = {}
343
+ const missingValues = {}
304
344
  for (const name of nameOrder(layered.layers)) {
305
345
  if (!isUnset(target, name)) {
306
346
  assign(
@@ -311,21 +351,37 @@ const fill = (input, { allowKeyCommand } = {}) => {
311
351
  } else if (Object.hasOwn(merged, name)) {
312
352
  const value = merged[name]
313
353
  assign(result, name, value)
314
- if (isExportable(name, value)) target[name] = toEnvValue(name, value)
354
+ if (isExportable(name, value)) {
355
+ assign(missingValues, name, toEnvValue(name, value))
356
+ }
315
357
  }
316
358
  }
317
- return result
359
+ return { target, env: layered.env, result, missing: missingValues }
318
360
  }
319
361
 
362
+ const config = input => {
363
+ const { result, env } = resolve(input)
364
+ return withAccessors(result, env)
365
+ }
366
+
367
+ const writeEnv = (input, options) => {
368
+ const { target, missing } = resolve(input, options)
369
+ for (const [name, value] of Object.entries(missing)) target[name] = value
370
+ }
371
+
372
+ const env = input => writeEnv(input)
373
+
320
374
  module.exports = {
321
375
  assertEnv,
322
376
  config,
323
377
  decryptDocument,
324
- fill,
378
+ env,
325
379
  get,
326
- load,
380
+ loadLayers,
327
381
  loadSecrets,
328
- names,
382
+ namesOf,
383
+ requireEnvLayer,
329
384
  readLayers,
385
+ writeEnv,
330
386
  resolveOptions
331
387
  }
package/src/store.js CHANGED
@@ -101,9 +101,12 @@ const parseErrorOf = error => {
101
101
  return error.code || error.message
102
102
  }
103
103
 
104
- const readDocument = file => {
104
+ const readDocument = file => readDocumentText(file).document
105
+
106
+ const readDocumentText = file => {
105
107
  try {
106
- return yaml.parse(fs.readFileSync(file, 'utf8'))
108
+ const text = fs.readFileSync(file, 'utf8')
109
+ return { text, document: yaml.parse(text) }
107
110
  } catch (error) {
108
111
  throw new OnesecretError(
109
112
  `cannot read ${file}: ${parseErrorOf(error)}`,
@@ -250,6 +253,7 @@ module.exports = {
250
253
  layerFileOrNew,
251
254
  layersFor,
252
255
  readDocument,
256
+ readDocumentText,
253
257
  readPlain,
254
258
  stringify,
255
259
  writeDocument,