onesecret 0.0.0-stage → 0.0.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/src/cli.js ADDED
@@ -0,0 +1,347 @@
1
+ 'use strict'
2
+
3
+ const { spawn, spawnSync } = require('node:child_process')
4
+ const fs = require('node:fs')
5
+ const os = require('node:os')
6
+ const path = require('node:path')
7
+ const mri = require('mri')
8
+ const secrets = require('./secrets')
9
+ const write = require('./write')
10
+ const deploy = require('./deploy')
11
+ const store = require('./store')
12
+ const { isEnvScalar, toDotenv } = require('./format')
13
+ const yaml = require('./yaml')
14
+ const { withoutAgeKey } = require('./keys')
15
+ const { OnesecretError } = require('./error')
16
+
17
+ const HELP = fs.readFileSync(
18
+ path.join(__dirname, '..', 'bin', 'help.txt'),
19
+ 'utf8'
20
+ )
21
+
22
+ const usage = message => {
23
+ throw new OnesecretError(`${message}\n\n${HELP}`, 'EUSAGE')
24
+ }
25
+
26
+ const readOptions = flags => ({
27
+ dir: flags.dir,
28
+ env: flags.env,
29
+ cwd: flags.cwd
30
+ })
31
+
32
+ const writeOptions = flags => ({ dir: flags.dir, cwd: flags.cwd })
33
+
34
+ const writeLayerOf = flags => flags.env || 'default'
35
+
36
+ const relative = file => path.relative(process.cwd(), file) || file
37
+
38
+ const shellQuote = text => `'${text.replace(/'/g, "'\\''")}'`
39
+
40
+ const WAIT_FLAGS = {
41
+ atom: '--wait',
42
+ bbedit: '-w',
43
+ clion: '--wait',
44
+ code: '--wait',
45
+ 'code-insiders': '--wait',
46
+ codium: '--wait',
47
+ cursor: '--wait',
48
+ gedit: '--wait',
49
+ goland: '--wait',
50
+ gvim: '-f',
51
+ idea: '--wait',
52
+ kate: '--block',
53
+ mate: '-w',
54
+ mvim: '-f',
55
+ nova: '--wait',
56
+ phpstorm: '--wait',
57
+ pycharm: '--wait',
58
+ rider: '--wait',
59
+ rubymine: '--wait',
60
+ subl: '--wait',
61
+ webstorm: '--wait',
62
+ windsurf: '--wait',
63
+ zed: '--wait'
64
+ }
65
+
66
+ const waitingEditor = editor => {
67
+ const [command, ...args] = editor.trim().split(/\s+/)
68
+ const flag = WAIT_FLAGS[path.basename(command)]
69
+ if (!flag || [flag, '-w', '--wait'].some(wait => args.includes(wait))) {
70
+ return editor
71
+ }
72
+ return `${editor} ${flag}`
73
+ }
74
+
75
+ const openEditor = file => {
76
+ const editor = waitingEditor(process.env.VISUAL || process.env.EDITOR || 'vi')
77
+ const { status, error } = spawnSync(`${editor} ${shellQuote(file)}`, {
78
+ stdio: 'inherit',
79
+ shell: true
80
+ })
81
+ if (error || status !== 0) {
82
+ throw new OnesecretError(
83
+ `editor "${editor}" exited without saving`,
84
+ 'EUSAGE'
85
+ )
86
+ }
87
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
88
+ }
89
+
90
+ const EDIT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP']
91
+
92
+ const assertOnlyFileOfLayer = (file, kind) => {
93
+ const dir = path.dirname(file)
94
+ const layer = secrets.assertEnv(store.layerOfFile(file))
95
+ const existing =
96
+ kind === 'plain'
97
+ ? store.findPlainFile(dir, layer)
98
+ : store.findLayerFile(dir, layer)
99
+ if (existing && existing !== file) {
100
+ throw new OnesecretError(
101
+ `${relative(existing)} already holds the ${layer} layer, edit it instead`,
102
+ 'EUSAGE'
103
+ )
104
+ }
105
+ }
106
+
107
+ const plainTarget = file => {
108
+ const original = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
109
+ return {
110
+ file,
111
+ original,
112
+ workspaceName: path.basename(file),
113
+ save: copy => {
114
+ try {
115
+ store.readPlain(copy)
116
+ } catch (error) {
117
+ throw new OnesecretError(
118
+ error.reason.split(copy).join(relative(file)),
119
+ 'EVALUE'
120
+ )
121
+ }
122
+ store.writeText(file, fs.readFileSync(copy, 'utf8'))
123
+ return `${relative(file)} saved`
124
+ }
125
+ }
126
+ }
127
+
128
+ const encryptedTarget = current => {
129
+ write.encryptionFor(current.file, current.existing)
130
+ return {
131
+ file: current.file,
132
+ original: write.toEditableYaml(current.tree),
133
+ workspaceName: path
134
+ .basename(current.file)
135
+ .replace(/\.enc\.(ya?ml|json)$/, '.yml'),
136
+ save: copy =>
137
+ `${relative(
138
+ write.writeLayer(
139
+ current,
140
+ write.fromEditableYaml(fs.readFileSync(copy, 'utf8'))
141
+ )
142
+ )} encrypted`
143
+ }
144
+ }
145
+
146
+ const editTargetOf = (flags, args) => {
147
+ if (args.length > 1) usage('edit takes at most one file')
148
+ if (args.length === 0) {
149
+ return encryptedTarget(
150
+ write.readLayer(writeOptions(flags), writeLayerOf(flags))
151
+ )
152
+ }
153
+ if (flags.env) usage('edit takes a file or --env, not both')
154
+ const file = path.resolve(flags.cwd || process.cwd(), args[0])
155
+ const kind = store.fileKindOf(file)
156
+ if (!kind) usage(`edit needs a .yml, .yaml, or .json file, got "${args[0]}"`)
157
+ assertOnlyFileOfLayer(file, kind)
158
+ return kind === 'plain'
159
+ ? plainTarget(file)
160
+ : encryptedTarget(write.readFile(writeOptions(flags), file))
161
+ }
162
+
163
+ const saveErrorOf = error =>
164
+ error instanceof OnesecretError
165
+ ? error.message
166
+ : `onesecret: cannot save, ${error.code || error.message}`
167
+
168
+ const editInWorkspace = (target, copy) => {
169
+ fs.writeFileSync(copy, target.original, { mode: 0o600 })
170
+ let failed = null
171
+ for (;;) {
172
+ const text = openEditor(copy)
173
+ if (text === target.original) return `${relative(target.file)} unchanged`
174
+ const retry = failed !== null && text === failed.text
175
+ if (retry && failed.invalid) {
176
+ throw new OnesecretError(
177
+ `edit aborted, ${relative(target.file)} is unchanged`,
178
+ 'EVALUE'
179
+ )
180
+ }
181
+ try {
182
+ return target.save(copy)
183
+ } catch (error) {
184
+ console.error(
185
+ `${saveErrorOf(error)}\nReopening the editor, save it unchanged to ${
186
+ error instanceof OnesecretError ? 'abort' : 'try again'
187
+ }.`
188
+ )
189
+ failed = { text, invalid: error instanceof OnesecretError }
190
+ }
191
+ }
192
+ }
193
+
194
+ const edit = (flags, args) => {
195
+ const target = editTargetOf(flags, args)
196
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'onesecret-'))
197
+ const removeWorkspace = () =>
198
+ fs.rmSync(workspace, { recursive: true, force: true })
199
+ const onSignal = signal => {
200
+ removeWorkspace()
201
+ process.exit(128 + os.constants.signals[signal])
202
+ }
203
+ for (const signal of EDIT_SIGNALS) process.on(signal, onSignal)
204
+ try {
205
+ return editInWorkspace(target, path.join(workspace, target.workspaceName))
206
+ } finally {
207
+ removeWorkspace()
208
+ for (const signal of EDIT_SIGNALS) process.off(signal, onSignal)
209
+ }
210
+ }
211
+
212
+ const exec = (flags, command) => {
213
+ if (command.length === 0) usage('exec needs a command after --')
214
+ const env = withoutAgeKey(process.env)
215
+ if (flags.env) env.NODE_ENV = flags.env
216
+ secrets.fill(
217
+ { ...readOptions(flags), processEnv: env },
218
+ { allowKeyCommand: true }
219
+ )
220
+ const child = spawn(command[0], command.slice(1), { stdio: 'inherit', env })
221
+ for (const signal of EDIT_SIGNALS) {
222
+ process.on(signal, () => child.kill(signal))
223
+ }
224
+ return new Promise((resolve, reject) => {
225
+ child.on('error', error =>
226
+ reject(
227
+ new OnesecretError(
228
+ `cannot run ${command[0]}: ${error.message}`,
229
+ 'EUSAGE'
230
+ )
231
+ )
232
+ )
233
+ child.on('exit', (code, signal) =>
234
+ resolve({ exitCode: signal ? 128 + os.constants.signals[signal] : code })
235
+ )
236
+ })
237
+ }
238
+
239
+ const COMMANDS = {
240
+ init: flags => {
241
+ const result = write.initialize(writeOptions(flags))
242
+ if (result.status === 'existing') {
243
+ return `already initialized for ${result.recipients.join(
244
+ ','
245
+ )} (${relative(result.defaultFile)})`
246
+ }
247
+ if (result.status === 'adopted') {
248
+ return `created ${relative(
249
+ result.defaultFile
250
+ )} for ${result.recipients.join(',')} (from ${result.source})`
251
+ }
252
+ return [
253
+ `public key ${result.recipients[0]}`,
254
+ `private key ${result.identity}`,
255
+ `secrets ${relative(result.defaultFile)}`,
256
+ '',
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:',
259
+ '',
260
+ ` export SOPS_AGE_KEY=${result.identity}`
261
+ ].join('\n')
262
+ },
263
+ 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`
268
+ },
269
+ unset: (flags, args) => {
270
+ if (args.length === 0) usage('unset needs at least one NAME')
271
+ return `${relative(
272
+ write.unset(writeOptions(flags), writeLayerOf(flags), args)
273
+ )} encrypted`
274
+ },
275
+ edit,
276
+ rekey: flags =>
277
+ `${relative(
278
+ write.rekey(writeOptions(flags), writeLayerOf(flags))
279
+ )} encrypted with a new data key`,
280
+ ls: flags => secrets.names(readOptions(flags)).join('\n'),
281
+ get: (flags, args) => {
282
+ if (args.length !== 1) usage('get needs exactly one NAME')
283
+ const values = secrets.get(readOptions(flags), args[0])
284
+ if (!Object.hasOwn(values, args[0])) {
285
+ throw new OnesecretError(`"${args[0]}" is not set in any layer`, 'EVALUE')
286
+ }
287
+ const value = values[args[0]]
288
+ if (value === null) return ''
289
+ return isEnvScalar(value) ? String(value) : yaml.dump(value).trimEnd()
290
+ },
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
305
+ },
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
+ }
315
+ }
316
+
317
+ const parse = argv => {
318
+ const separator = argv.indexOf('--')
319
+ const own = separator === -1 ? argv : argv.slice(0, separator)
320
+ const rest = separator === -1 ? [] : argv.slice(separator + 1)
321
+ const {
322
+ _: [command, ...args],
323
+ ...flags
324
+ } = 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 }
329
+ })
330
+ return {
331
+ command,
332
+ args: command === 'exec' ? rest : [...args, ...rest],
333
+ flags
334
+ }
335
+ }
336
+
337
+ const run = async argv => {
338
+ const { command, args, flags } = parse(argv)
339
+ if (flags.version) return { stdout: require('../package.json').version }
340
+ if (flags.help || !command) return { stdout: HELP.trimEnd() }
341
+ const handler = COMMANDS[command]
342
+ if (!handler) usage(`unknown command "${command}"`)
343
+ const result = await handler(flags, args)
344
+ return typeof result === 'object' ? result : { stdout: result }
345
+ }
346
+
347
+ module.exports = { run }
package/src/deploy.js ADDED
@@ -0,0 +1,113 @@
1
+ 'use strict'
2
+
3
+ const { execFileSync } = require('node:child_process')
4
+ const yaml = require('js-yaml')
5
+ const { loadSecrets, readLayers } = require('./secrets')
6
+ const { withoutAgeKey } = require('./keys')
7
+ const { checksum, toEnv } = require('./format')
8
+ const flyEncoding = require('./fly')
9
+ const { OnesecretError } = require('./error')
10
+
11
+ const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by'
12
+
13
+ const loadForDeploy = (target, options) => {
14
+ if (!options.env) {
15
+ throw new OnesecretError(
16
+ `${target} needs an explicit env, such as "production"`,
17
+ 'EVALUE'
18
+ )
19
+ }
20
+ const layered = readLayers(options)
21
+ const envLayer = layered.layers.find(({ name }) => name === layered.env)
22
+ if (!envLayer.plainFile && !envLayer.encryptedFile) {
23
+ throw new OnesecretError(
24
+ `${target} found no "${layered.env}" layer in ${layered.dir}, pass --env default to deploy the default layer alone`,
25
+ 'EVALUE'
26
+ )
27
+ }
28
+ return loadSecrets(layered)
29
+ }
30
+
31
+ const run = (binary, args, input) => {
32
+ try {
33
+ return execFileSync(binary, args, {
34
+ input,
35
+ encoding: 'utf8',
36
+ env: withoutAgeKey(process.env),
37
+ stdio: ['pipe', 'pipe', 'pipe']
38
+ })
39
+ } catch (error) {
40
+ const output = [error.stdout, error.stderr].filter(Boolean).join('\n')
41
+ const redacted = output.replace(/=.*/g, '=***').trim()
42
+ const reason =
43
+ error.code === 'ENOENT'
44
+ ? `${binary} is not installed`
45
+ : redacted || error.message
46
+ throw new OnesecretError(
47
+ `${binary} ${args.slice(0, 3).join(' ')} failed: ${reason}`,
48
+ 'EDEPLOY'
49
+ )
50
+ }
51
+ }
52
+
53
+ const kubernetesManifest = ({ name, namespace, secrets }) =>
54
+ yaml.dump(
55
+ {
56
+ apiVersion: 'v1',
57
+ kind: 'Secret',
58
+ metadata: {
59
+ name,
60
+ ...(namespace && { namespace }),
61
+ labels: { [MANAGED_BY_LABEL]: 'onesecret' }
62
+ },
63
+ type: 'Opaque',
64
+ data: Object.fromEntries(
65
+ Object.entries(toEnv(secrets)).map(([key, value]) => [
66
+ key,
67
+ Buffer.from(value).toString('base64')
68
+ ])
69
+ )
70
+ },
71
+ { lineWidth: -1 }
72
+ )
73
+
74
+ const kubernetes = ({
75
+ name,
76
+ namespace,
77
+ context,
78
+ kubectl = 'kubectl',
79
+ args = [],
80
+ ...options
81
+ }) => {
82
+ if (!name) {
83
+ throw new OnesecretError('kubernetes needs the Secret name', 'EVALUE')
84
+ }
85
+ const secrets = loadForDeploy('kubernetes', options)
86
+ const contextArgs = context ? ['--context', context] : []
87
+ const output = run(
88
+ kubectl,
89
+ [...contextArgs, ...args, 'apply', '-f', '-'],
90
+ kubernetesManifest({ name, namespace, secrets })
91
+ )
92
+ return { checksum: checksum(secrets), output: output.trim() }
93
+ }
94
+
95
+ const flyImport = secrets => flyEncoding.encode(toEnv(secrets))
96
+
97
+ const fly = ({ app, stage = true, flyctl = 'fly', ...options }) => {
98
+ if (!app) throw new OnesecretError('fly needs the app name', 'EVALUE')
99
+ const secrets = loadForDeploy('fly', options)
100
+ const stageArgs = stage ? ['--stage'] : []
101
+ const output = run(
102
+ flyctl,
103
+ ['secrets', 'import', ...stageArgs, '--app', app],
104
+ flyImport(secrets)
105
+ )
106
+ const summary = output
107
+ .split('\n')
108
+ .filter(line => line.trim() && !line.includes('='))
109
+ .join('\n')
110
+ return { checksum: checksum(secrets), output: summary }
111
+ }
112
+
113
+ module.exports = { fly, flyImport, kubernetes, kubernetesManifest }
package/src/dotenv.js ADDED
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+
3
+ const { parseEnv } = require('node:util')
4
+ const { isDeepStrictEqual } = require('node:util')
5
+ const { OnesecretError } = require('./error')
6
+
7
+ const DOTENV_LINE =
8
+ /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm
9
+ const QUOTES = ["'", '`', '"']
10
+ const NEIGHBOR = 'ONESECRET_NEIGHBOR'
11
+
12
+ const parseDotenvPackage = source => {
13
+ const secrets = {}
14
+ const lines = source.replace(/\r\n?/gm, '\n')
15
+ for (const match of lines.matchAll(DOTENV_LINE)) {
16
+ const quote = (match[2] || '').trim()[0]
17
+ let value = (match[2] || '').trim().replace(/^(['"`])([\s\S]*)\1$/gm, '$2')
18
+ if (quote === '"') value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r')
19
+ secrets[match[1]] = value
20
+ }
21
+ return secrets
22
+ }
23
+
24
+ const PARSERS = [source => ({ ...parseEnv(source) }), parseDotenvPackage]
25
+
26
+ const readsBackAs = (source, expected) =>
27
+ PARSERS.every(parse => isDeepStrictEqual(parse(source), expected))
28
+
29
+ const survivesNeighbors = (line, name, value) =>
30
+ QUOTES.every(quote =>
31
+ readsBackAs(`${line}${NEIGHBOR}=${quote}#${quote}\n`, {
32
+ [name]: value,
33
+ [NEIGHBOR]: '#'
34
+ })
35
+ )
36
+
37
+ const encodeLine = (name, value) => {
38
+ const line = [value, ...QUOTES.map(quote => quote + value + quote)]
39
+ .map(encoded => `${name}=${encoded}\n`)
40
+ .find(
41
+ line =>
42
+ readsBackAs(line, { [name]: value }) &&
43
+ survivesNeighbors(line, name, value)
44
+ )
45
+ if (!line) {
46
+ throw new OnesecretError(
47
+ `"${name}" cannot be written as dotenv without changing its value`,
48
+ 'EVALUE'
49
+ )
50
+ }
51
+ return line
52
+ }
53
+
54
+ const encode = secrets => {
55
+ const body = Object.entries(secrets)
56
+ .map(([name, value]) => encodeLine(name, value))
57
+ .join('')
58
+ if (!readsBackAs(body, secrets)) {
59
+ throw new OnesecretError(
60
+ 'the dotenv output does not read back to the same secrets',
61
+ 'EVALUE'
62
+ )
63
+ }
64
+ return body
65
+ }
66
+
67
+ module.exports = { encode, parseDotenvPackage }
package/src/error.js ADDED
@@ -0,0 +1,12 @@
1
+ 'use strict'
2
+
3
+ class OnesecretError extends Error {
4
+ constructor (message, code) {
5
+ super(`onesecret: ${message}`)
6
+ this.name = 'OnesecretError'
7
+ this.code = code
8
+ this.reason = message
9
+ }
10
+ }
11
+
12
+ module.exports = { OnesecretError }
package/src/fly.js ADDED
@@ -0,0 +1,109 @@
1
+ 'use strict'
2
+
3
+ const { isDeepStrictEqual } = require('node:util')
4
+ const { OnesecretError } = require('./error')
5
+
6
+ const TRIPLE_QUOTE = '"""'
7
+ const SCANNER_MAX_LINE_BYTES = 64 * 1024 - 1
8
+
9
+ const scanLines = text => {
10
+ const lines = text.split('\n')
11
+ if (lines[lines.length - 1] === '') lines.pop()
12
+ return lines.map(line => line.replace(/\r$/, ''))
13
+ }
14
+
15
+ const countQuotes = text => text.split('"').length - 1
16
+
17
+ const parseSinglelineValue = line => {
18
+ const separator = line.indexOf('=')
19
+ let value = line.slice(separator + 1).replace(/^ +/, '')
20
+ const hash = value.indexOf('#')
21
+ if (hash !== -1 && countQuotes(value.slice(0, hash)) % 2 === 0) { value = value.slice(0, hash).replace(/ +$/, '') }
22
+ return { key: line.slice(0, separator).trim(), value }
23
+ }
24
+
25
+ const parse = text => {
26
+ const secrets = {}
27
+ let multiline = null
28
+ for (const line of scanLines(text)) {
29
+ if (multiline) {
30
+ if (line.endsWith(TRIPLE_QUOTE)) {
31
+ secrets[multiline.key] =
32
+ multiline.value + line.slice(0, -TRIPLE_QUOTE.length)
33
+ multiline = null
34
+ } else {
35
+ multiline.value += `${line}\n`
36
+ }
37
+ continue
38
+ }
39
+ if (line.startsWith('#') || line.trim() === '') continue
40
+ if (!line.includes('=')) throw new Error(`"${line}" is not NAME=VALUE`)
41
+ const { key, value } = parseSinglelineValue(line)
42
+ if (
43
+ value.startsWith(TRIPLE_QUOTE) &&
44
+ value.endsWith(TRIPLE_QUOTE) &&
45
+ value.length >= 6
46
+ ) {
47
+ secrets[key] = value.slice(3, -3)
48
+ } else if (value.startsWith(TRIPLE_QUOTE)) {
49
+ multiline = { key, value: `${value.slice(3)}\n` }
50
+ } else if (
51
+ (value.startsWith('"') && value.endsWith('"')) ||
52
+ (value.startsWith("'") && value.endsWith("'"))
53
+ ) {
54
+ if (value.length < 2) throw new Error('flyctl panics on a lone quote')
55
+ secrets[key] = value.slice(1, -1)
56
+ } else {
57
+ secrets[key] = value
58
+ }
59
+ }
60
+ return secrets
61
+ }
62
+
63
+ const candidates = value => [
64
+ value,
65
+ `${TRIPLE_QUOTE}${value}${TRIPLE_QUOTE}`,
66
+ `"${value}"`,
67
+ `'${value}'`
68
+ ]
69
+
70
+ const readsBackAs = (text, expected) => {
71
+ try {
72
+ return isDeepStrictEqual(parse(text), expected)
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
78
+ const isWithinScanner = text =>
79
+ scanLines(text).every(
80
+ line => Buffer.byteLength(line) <= SCANNER_MAX_LINE_BYTES
81
+ )
82
+
83
+ const encodeLine = (name, value) => {
84
+ const line = candidates(value)
85
+ .map(encoded => `${name}=${encoded}\n`)
86
+ .find(line => isWithinScanner(line) && readsBackAs(line, { [name]: value }))
87
+ if (!line) {
88
+ throw new OnesecretError(
89
+ `"${name}" cannot be imported into fly without changing its value`,
90
+ 'EVALUE'
91
+ )
92
+ }
93
+ return line
94
+ }
95
+
96
+ const encode = secrets => {
97
+ const body = Object.entries(secrets)
98
+ .map(([name, value]) => encodeLine(name, value))
99
+ .join('')
100
+ if (!readsBackAs(body, secrets)) {
101
+ throw new OnesecretError(
102
+ 'the fly import does not read back to the same secrets',
103
+ 'EVALUE'
104
+ )
105
+ }
106
+ return body
107
+ }
108
+
109
+ module.exports = { encode, parse }
package/src/format.js ADDED
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('node:crypto')
4
+ const dotenv = require('./dotenv')
5
+ const { OnesecretError } = require('./error')
6
+
7
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
8
+
9
+ const isEnvName = name => ENV_NAME.test(name) && name !== '__proto__'
10
+
11
+ const assertEnvName = name => {
12
+ if (!isEnvName(name)) {
13
+ throw new OnesecretError(
14
+ `"${name}" is not a valid environment variable name`,
15
+ 'EVALUE'
16
+ )
17
+ }
18
+ }
19
+
20
+ const isEnvScalar = value =>
21
+ ['string', 'number', 'boolean', 'bigint'].includes(typeof value)
22
+
23
+ const toEnvValue = (name, value) => {
24
+ assertEnvName(name)
25
+ if (value === null || value === undefined) return ''
26
+ if (isEnvScalar(value)) return String(value)
27
+ throw new OnesecretError(
28
+ `"${name}" must be a string, number, or boolean to become an environment variable`,
29
+ 'EVALUE'
30
+ )
31
+ }
32
+
33
+ const toEnv = secrets =>
34
+ Object.fromEntries(
35
+ Object.entries(secrets).map(([name, value]) => [
36
+ name,
37
+ toEnvValue(name, value)
38
+ ])
39
+ )
40
+
41
+ const toDotenv = secrets => dotenv.encode(toEnv(secrets))
42
+
43
+ const checksum = secrets => {
44
+ const entries = Object.entries(toEnv(secrets)).sort(([a], [b]) =>
45
+ a < b ? -1 : a > b ? 1 : 0
46
+ )
47
+ return crypto
48
+ .createHash('sha256')
49
+ .update(JSON.stringify(entries))
50
+ .digest('hex')
51
+ }
52
+
53
+ module.exports = {
54
+ assertEnvName,
55
+ checksum,
56
+ isEnvName,
57
+ isEnvScalar,
58
+ toDotenv,
59
+ toEnv,
60
+ toEnvValue
61
+ }