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/README.md +561 -116
- package/bin/help.txt +9 -11
- package/bin/index.js +4 -4
- package/package.json +37 -6
- package/src/accessors.js +70 -0
- package/src/cli.js +105 -43
- package/src/index.js +2 -6
- package/src/secrets.js +76 -20
- package/src/store.js +6 -2
- package/src/write.js +18 -12
- package/src/deploy.js +0 -113
- package/src/fly.js +0 -109
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
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
|
33
|
-
$ onesecret set --env production
|
|
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
|
|
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(
|
|
8
|
-
|
|
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://
|
|
5
|
-
"version": "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": "(
|
|
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": "
|
|
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:
|
|
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
|
}
|
package/src/accessors.js
ADDED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
|
258
|
-
'
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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', '
|
|
326
|
-
string: ['dir', 'env', 'cwd'
|
|
327
|
-
alias: { h: 'help', v: 'version', e: 'env'
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
217
|
+
const namesOf = layered => secretNamesOf(layered.layers)
|
|
181
218
|
|
|
182
|
-
const
|
|
183
|
-
|
|
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
|
|
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
|
|
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
|
|
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))
|
|
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
|
-
|
|
378
|
+
env,
|
|
325
379
|
get,
|
|
326
|
-
|
|
380
|
+
loadLayers,
|
|
327
381
|
loadSecrets,
|
|
328
|
-
|
|
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
|
-
|
|
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,
|