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 CHANGED
@@ -6,123 +6,210 @@
6
6
 
7
7
  > Keep just one secret to decrypt the rest.
8
8
 
9
- Commit your credentials encrypted next to your code, and keep a single age key to read them. The files are [SOPS](https://github.com/getsops/sops) files, so the `sops` CLI and every tool around it keep working, but nothing has to be installed: encryption and decryption run in Node.js with no native binaries.
9
+ Put your app's passwords, tokens, and API keys in your repository, encrypted, right next to your code. One key unlocks all of them, and your servers don't even need that key: when you deploy, you hand the values to your hosting platform, and your app finds them already set.
10
10
 
11
- - **One key.** One age key decrypts every environment. It lives in CI and in your team's password manager, never in the image and never in the running app.
12
- - **Layers.** `config/default.yml` and `config/default.enc.yml` hold what every environment shares, and `config/production.yml` and `config/production.enc.yml` override them for production. Plain settings and secrets live side by side, and `config()` returns them as one object.
13
- - **Production never needs the key.** Deploy pushes the decrypted values to the platform (a Kubernetes Secret, Fly secrets). At boot, `config()` sees every value already in the environment and decrypts nothing. In `production` and `staging` it never runs `keyCommand`, so a missing secret fails the boot instead of fetching a key.
14
- - **Fails fast.** Secret names are stored in plaintext, so `config()` knows what must exist and names exactly what is missing.
15
- - **Fast.** After about 9ms to load the module, the first `config()` call takes about 5ms when it decrypts two layers, against roughly 30ms for spawning `sops -d` twice, and about 4ms when the platform already injected every secret and nothing is decrypted. Later calls take about 0.3ms.
11
+ ## Why onesecret
12
+
13
+ - **Your secrets are never exposed in your repository.** They are always encrypted there, so anyone who sees the code, a fork, or the git history sees nothing usable. In production your hosting platform holds the values as environment variables, the standard way to run secrets, and the key never reaches your servers. If a server leaks, it leaks only its own values.
14
+ - **One key unlocks everything.** Every secret, in every environment, opens with the same key. Onboarding a teammate or a CI job means sharing that one key, and nothing else.
15
+ - **Works on any platform.** Your app reads plain environment variables, which every host supports: Vercel, Fly.io, Kubernetes, AWS, Docker, or your own server. There is no secrets service to sign up for, pay for, or migrate away from.
16
+ - **Versioned with your code.** Secrets and plain settings live side by side in one `config/` folder, so a change ships in the same commit and pull request as the code that needs it, with history and review.
16
17
 
17
18
  ## Install
18
19
 
20
+ Requires Node.js 24 or newer.
21
+
19
22
  ```bash
20
23
  $ npm install onesecret
21
24
  ```
22
25
 
23
26
  ## Quick start
24
27
 
28
+ **1. Create your key.**
29
+
25
30
  ```bash
26
31
  $ npx onesecret init
32
+
27
33
  public key age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
28
34
  private key AGE-SECRET-KEY-1…
29
35
  secrets config/default.enc.yml
30
36
 
31
- The private key is shown only now and is not saved anywhere. Keep it in your
32
- password manager and as SOPS_AGE_KEY in CI. To use it in this shell:
37
+ The private key is the only secret to keep: it decrypts all the others.
38
+ It is shown only now and saved nowhere, so store it safely (and as
39
+ SOPS_AGE_KEY in CI). To use it in this shell:
33
40
 
34
- export SOPS_AGE_KEY=AGE-SECRET-KEY-1…
41
+ export SOPS_AGE_KEY=AGE-SECRET-KEY-1…
42
+ ```
35
43
 
36
- $ export SOPS_AGE_KEY=AGE-SECRET-KEY-1…
37
- $ npx onesecret set DATABASE_URL=postgres://localhost/app STRIPE_KEY=sk_test_123
38
- $ npx onesecret set --env production DATABASE_URL=postgres://db.internal/app
39
- $ git add config
44
+ The private key is the only secret you need to keep: it decrypts all the others. It is shown only once and saved nowhere, so store it somewhere safe and never commit it. You don't need to keep the public key, since it is already written into the encrypted file.
45
+
46
+ **2. Add your settings and secrets, then commit them.**
47
+
48
+ Everything lives in the `config/` folder. Plain settings go in `.yml` files, secrets go encrypted in `.enc.yml` files, and each file name is an environment. This step ends with:
49
+
50
+ ```
51
+ config/default.yml ← PORT, for every environment
52
+ config/default.enc.yml ← DATABASE_URL, encrypted, for every environment
53
+ config/production.enc.yml ← DATABASE_URL, encrypted, for production only
54
+ ```
55
+
56
+ `default` is the base. An environment such as `production` adds its own values on top and wins where both have the same name.
57
+
58
+ Run the `export` line that `init` printed, then:
59
+
60
+ ```bash
61
+ $ echo 'PORT: 3000' > config/default.yml
62
+ $ npx onesecret set DATABASE_URL
63
+ $ npx onesecret set --env production DATABASE_URL
64
+ $ git add config && git commit -m "Add config"
40
65
  ```
41
66
 
42
- `init` prints the private key once and writes it nowhere, so it never sits in a file on your disk. Save it in your password manager before closing the terminal: without it, nobody can decrypt the files.
67
+ `set` opens your editor (`$VISUAL` or `$EDITOR`, otherwise `vi`) for the value, so it never ends up in your shell history. In a script, pipe it in instead: `printf %s "$VALUE" | npx onesecret set NAME`.
43
68
 
44
- There is no other configuration. Each encrypted file records the public key it is locked for, so a new layer such as `config/staging.enc.yml` is locked for the same key as `config/default.enc.yml` in its folder, with the same encryption rules. A file whose folder has no `default.enc.yml` is locked for the public key of the `SOPS_AGE_KEY` you exported. To join an existing project, export its key first: `init` then uses it and prints no new key.
69
+ **3. Load them when your app starts.**
45
70
 
46
- Load them as the first thing your app does:
71
+ `config()` returns your settings and secrets as one object:
47
72
 
48
73
  ```js
49
- const config = require('onesecret').config()
74
+ const onesecret = require('onesecret')
50
75
 
51
- console.log(config.DATABASE_URL)
52
- console.log(process.env.DATABASE_URL)
76
+ // it reads process.env by default
77
+ const config = onesecret.config()
78
+
79
+ // Read any setting or secret by name
80
+ config.PORT // 3000
81
+ config.DATABASE_URL // 'postgres://…'
82
+
83
+ // Optional values: fall back to a default when unset
84
+ config.get('LOG_LEVEL', 'info') // 'info'
85
+ config.get('database.pool', 10) // a dot path reaches nested settings
86
+
87
+ // Check before using something optional
88
+ if (config.has('SENTRY_DSN')) {
89
+ // set up error tracking
90
+ }
91
+
92
+ // Values your app cannot run without: fail at startup, naming what is missing
93
+ config.require('DATABASE_URL') // 'postgres://…'
94
+ const { DATABASE_URL, PORT } = config.required // the same, for several names at once
95
+
96
+ // OnesecretError: onesecret: "DATABASE_URL" is required but not set in the "production" config
97
+ ```
98
+
99
+ If your code already reads `process.env`, as with dotenv, call `env()` instead. It puts every value into `process.env`:
100
+
101
+ ```js
102
+ require('onesecret').env()
103
+
104
+ process.env.DATABASE_URL // 'postgres://…'
105
+ ```
106
+
107
+ onesecret decrypts with the key you exported. Without a key, it stops and tells you which secret it could not read:
108
+
109
+ ```
110
+ onesecret: DATABASE_URL is not set and cannot be decrypted, no age key found: …
53
111
  ```
54
112
 
55
- `config()` reads the layer named by `NODE_ENV`. To pick another one, pass its name: `config('staging')`.
113
+ To see it all working, run the [example app](https://github.com/Kikobeats/onesecret/tree/master/example).
56
114
 
57
- To change a secret, open its file. You edit it decrypted, and it is encrypted again when you save:
115
+ ## Everyday tasks
116
+
117
+ ### Add or change a secret
118
+
119
+ ```bash
120
+ $ npx onesecret set API_TOKEN
121
+ $ npx onesecret set --env production API_TOKEN
122
+ ```
123
+
124
+ To change several at once, open the whole file. You edit it decrypted, and it is encrypted again when you save:
58
125
 
59
126
  ```bash
60
127
  $ npx onesecret edit config/production.enc.yml
61
128
  ```
62
129
 
63
- Or run anything with them, without touching its code:
130
+ ### See what's stored
131
+
132
+ List the secret names (no key needed), or print one value:
133
+
134
+ ```bash
135
+ $ npx onesecret ls
136
+ $ npx onesecret get DATABASE_URL
137
+ ```
138
+
139
+ ### Run a command with your secrets
140
+
141
+ Any command, without changing its code:
64
142
 
65
143
  ```bash
66
144
  $ npx onesecret exec -- node server.js
67
145
  ```
68
146
 
69
- [`example/`](example) is a runnable app that shows each of these cases.
147
+ ### Run your tests
70
148
 
71
- ## How it works
149
+ Most tests don't need real credentials. With `ONESECRET_SKIP=1`, nothing is decrypted: your plain settings still load, and secrets stay unset.
72
150
 
151
+ ```bash
152
+ $ ONESECRET_SKIP=1 npm test
73
153
  ```
74
- config/default.yml ← plain settings shared by every environment
75
- config/default.enc.yml ← secrets shared by every environment
76
- config/production.yml ← plain settings for NODE_ENV=production
77
- config/production.enc.yml ← secrets for NODE_ENV=production
154
+
155
+ Tests that do need credentials can run under `onesecret exec -- npm test`, which passes the values, but never the key, to the test runner.
156
+
157
+ ### Join a project
158
+
159
+ Get the key from whoever set up the project and export it. The encrypted files already say which key opens them, so there is nothing else to set up.
160
+
161
+ ```bash
162
+ $ export SOPS_AGE_KEY=AGE-SECRET-KEY-1…
163
+ $ npx onesecret set --env staging DATABASE_URL
78
164
  ```
79
165
 
80
- Every file is optional. They are merged in that order, so a later file wins, and nested objects are merged key by key. Plain files are YAML (`.yml`, `.yaml`) or JSON (`.json`), read with [js-yaml](https://github.com/nodeca/js-yaml); an empty file is an empty layer. Each layer has at most one plain and one encrypted file, so `default.yml` next to `default.json` is an error, and so is a sops-encrypted file named like a plain one.
166
+ ### Move an existing project to onesecret
81
167
 
82
- A name is a secret while an encrypted file contributes to its merged value. A plain file that replaces a secret from a lower layer makes the name plain config for that env; a plain mapping merged into an encrypted one keeps it a secret. `config()` reads the secret names (they are plaintext in the encrypted files) and checks `process.env`:
168
+ Give your coding agent this prompt:
83
169
 
84
- 1. Every secret is already set: nothing is decrypted, and the key is not needed. This is production. A variable set to an empty string counts as set.
85
- 2. Some secrets are missing: it finds the age key, decrypts, and fills **only** the missing ones.
86
- 3. Some secrets are missing and there is no key: it throws, naming each missing secret.
170
+ ```
171
+ Migrate this project to onesecret: run npx onesecret init (keep the printed key out of the repo), move plain settings to config/<env>.yml, encrypt each secret by piping its value into npx onesecret set --env <env> NAME, replace dotenv with require('onesecret').env(), and verify every value matches the old .env without printing it.
172
+ ```
87
173
 
88
- Values already in the environment always win, for plain settings too. Every other top-level value that is a string, number, boolean, or `null` is written into `process.env` as a string. `config()` returns the merged object with the YAML types kept, so `TIMEOUT: 60000` comes back as the number `60000`, while `process.env.TIMEOUT` is `"60000"`. Nested objects and lists, and names that are not valid variable names (such as `log-level`), are returned but not written into `process.env`. A value the environment provides comes back with its file's type when the string matches it, so `PORT=3000` is the number `3000` again, even for a secret, whose type is plaintext in the encrypted file. A secret must be a string, number, or boolean, since the platform injects it as a variable: a mapping or list secret missing from the environment fails with `EFORMAT`.
174
+ ## Deploy
89
175
 
90
- Encrypted files accept only the standard tags `!!str`, `!!int`, `!!float`, `!!bool`, `!!null`, and `!!timestamp`, on values, so a value such as `!Passw0rd` must be quoted. A YAML error names the line and column but never the text, so a broken line cannot print a secret. Env names are lowercase letters, digits, `.`, `_`, and `-`, without `..`, an `enc` part, or a file extension, so `NODE_ENV=Production` fails at boot instead of reading another file. Layer files must match that exact name: `Production.yml` next to a `production` layer is refused, so a layer reads the same on every filesystem. It also throws when it finds no file at all, so an image that forgot to copy `config/`, or a process started from the wrong directory, fails at boot instead of running without its configuration.
176
+ In production, your deploy script sends the secret values to your hosting platform (Vercel, Fly.io, Kubernetes, AWS, and so on), which sets them as environment variables. At startup, your app finds them already set and decrypts nothing.
91
177
 
92
- The age key is read from these sources, and every key found is tried:
178
+ Ship the `config/` folder with your app. It holds your plain settings and the list of secret names, so the app can still tell you if one is missing.
93
179
 
94
- - `SOPS_AGE_KEY`, the key itself (the usual choice in CI).
95
- - `SOPS_AGE_KEY_FILE`, a path to a keys file.
96
- - The sops keys file: `$XDG_CONFIG_HOME/sops/age/keys.txt`, `~/Library/Application Support/sops/age/keys.txt` on macOS, and `~/.config/sops/age/keys.txt` on any system.
180
+ Pick your platform below. Most recipes use [jq](https://jqlang.org) to handle the values.
97
181
 
98
- A source can hold several keys, one per line or separated by spaces. Post-quantum (`AGE-SECRET-KEY-PQ-`) and plugin identities are skipped, and SSH keys are not read. A source that cannot be read or parsed, such as a keys file holding an SSH key, is skipped when another source has a key; when no key opens the file, the error names the sources it skipped.
182
+ <details>
183
+ <summary data-logo="recipes"><strong>How the recipes work</strong></summary>
99
184
 
100
- When none of them opens the file, `SOPS_AGE_KEY_CMD` or the `keyCommand` setting runs: a command that prints the key. It runs at most once per process, whether it succeeds or fails, and `config()` never runs it in `production` or `staging`. `load()` and the CLI (`exec`, `get`, `dotenv`, deploys) do run it in any env, since you call them on purpose.
185
+ Deploys push only the secrets, with their merged values. Plain settings ship inside the image with the `config/` directory.
101
186
 
102
- `keyCommand` comes from the project's `package.json`, so it has the same trust model as an npm script: only run onesecret in repositories you trust.
187
+ Keep the `config/` directory in your image: `config()` reads the plain settings from it and the secret names to check that the platform injected every secret, and fails at boot if one is missing.
103
188
 
104
- With `keyCommand`, nobody keeps the key on disk. For example, to read it from a GitLab CI variable (`glab`) or from 1Password (`op`):
189
+ Every provider is fed with three primitives:
105
190
 
106
- ```json
107
- {
108
- "onesecret": {
109
- "keyCommand": "glab variable get SOPS_AGE_KEY -R my-group/my-project"
110
- }
111
- }
112
- ```
191
+ - `onesecret dotenv --json` prints every secret as one JSON object, for APIs and CLIs that take JSON.
192
+ - `onesecret ls` prints the secret names, and `onesecret get NAME` prints one value. When piped, `get` prints the exact bytes with no trailing newline.
193
+ - `onesecret exec -- <command>` runs a command with the secrets in its environment.
113
194
 
114
- `exec`, `kubernetes`, and `fly` remove `SOPS_AGE_KEY`, `SOPS_AGE_KEY_FILE`, and `SOPS_AGE_KEY_CMD` from the environment they pass on, so the command you run gets the secrets but not the key. The keys file stays readable on disk to anything running as you, and `edit` and `keyCommand` run with your full environment.
195
+ Always pass `--env` to them in a deploy script: without it they read `NODE_ENV`, then `development`. With `--env`, `dotenv` and `ls` refuse a layer that has no plain or encrypted file, so a typo such as `--env prodution` or a wrong `--dir` fails instead of pushing the default layer. To push the default layer alone, pass `--env default`.
115
196
 
116
- ## Deploy
197
+ Each recipe below decrypts once into a `secrets` variable and pushes only when that worked, so a missing key sends nothing instead of empty values. In a script, start with `set -euo pipefail`, so a failed decrypt also stops the deploy that follows.
117
198
 
118
- ### Kubernetes
199
+ </details>
119
200
 
120
- Deploys push only the secrets, with their merged values. Plain settings ship inside the image with the `config/` directory.
201
+ <details>
202
+ <summary data-logo="kubernetes"><strong>Kubernetes</strong></summary>
121
203
 
122
- Write the secrets into a Secret before `helm upgrade`, then pass the printed checksum to the chart so pods roll when a value changes:
204
+ Apply the secrets as a Secret before `helm upgrade`, then pass their checksum to the chart so pods roll when a value changes:
123
205
 
124
206
  ```bash
125
- $ checksum=$(npx onesecret kubernetes --env production --name api-env --namespace api --context prod)
207
+ $ secrets=$(npx onesecret dotenv --env production --json) &&
208
+ printf %s "$secrets" | jq '{apiVersion: "v1", kind: "Secret", type: "Opaque",
209
+ metadata: {name: "api-env", labels: {"app.kubernetes.io/managed-by": "onesecret"}},
210
+ data: map_values(@base64)}' |
211
+ kubectl apply --namespace api --context prod -f -
212
+ $ checksum=$(printf %s "$secrets" | jq -S -c . | shasum -a 256 | cut -d' ' -f1)
126
213
  $ helm upgrade api ./chart --set-string envSecretChecksum=$checksum
127
214
  ```
128
215
 
@@ -141,20 +228,225 @@ spec:
141
228
  name: api-env
142
229
  ```
143
230
 
144
- Apply the Secret before every install, including one-off installs such as a canary, because a pod that reads a Secret that does not exist yet never becomes ready. The checksum annotation matters too: when only a secret value changes, nothing else in the pod template changes, so without it Kubernetes keeps the old pods running with the old values.
231
+ Apply the Secret before every install, including one-off installs such as a canary, because a pod that reads a Secret that does not exist yet never becomes ready.
232
+
233
+ The checksum annotation matters too: when only a secret value changes, nothing else in the pod template changes, so without it Kubernetes keeps the old pods running with the old values.
234
+
235
+ - `kubectl apply` replaces the whole `data`, so a secret that moved to a plain file disappears from the Secret.
236
+ - Do not use `kubectl create secret --from-env-file` with `onesecret dotenv`: kubectl keeps quotes as part of the value and cannot read multi-line values.
145
237
 
146
- ### Fly.io
238
+ </details>
239
+
240
+ <details>
241
+ <summary data-logo="flyio"><strong>Fly.io</strong></summary>
147
242
 
148
243
  ```bash
149
- $ npx onesecret fly --env production --app my-app
244
+ $ secrets=$(npx onesecret dotenv --env production --json) &&
245
+ printf %s "$secrets" | jq -j 'to_entries[] | "\(.key)=\(.value)\u0000"' | xargs -0 fly secrets set --stage --app my-app
150
246
  $ fly deploy --app my-app
151
247
  ```
152
248
 
153
- `kubernetes` and `fly` require `--env`, and that layer must have a plain or an encrypted file, so a typo such as `--env prodution` or a wrong `--dir` fails instead of pushing the wrong secrets. To deploy the default layer alone, pass `--env default`.
249
+ `fly secrets set` splits each argument at the first `=` and keeps the rest as it is, so `#`, quotes, and multi-line values such as PEM keys arrive unchanged. `--stage` applies them with the next deploy; drop it to restart the machines with them right away.
250
+
251
+ - The values are arguments of `fly` while it runs, so they are visible in the process list of that machine.
252
+ - flyctl reads a value of exactly `-` from stdin instead.
253
+ - Keep secret names out of the `[env]` section of `fly.toml`, since those values would be stored as plain configuration.
254
+ - `fly secrets set` adds and updates but never removes, so when a secret moves to a plain file, run `fly secrets unset NAME`, or the old secret keeps overriding the file.
255
+
256
+ </details>
257
+
258
+ <details>
259
+ <summary data-logo="vercel"><strong>Vercel</strong></summary>
260
+
261
+ ```bash
262
+ $ secrets=$(npx onesecret dotenv --env production --json)
263
+ $ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
264
+ printf %s "$secrets" | jq -j --arg name "$name" '.[$name]' |
265
+ vercel env add "$name" --environment production --sensitive --force --yes
266
+ done
267
+ $ vercel deploy --prod
268
+ ```
269
+
270
+ - New values apply only to new deployments.
271
+ - `vercel env add` adds and overwrites but never removes: run `vercel env rm NAME --environment production --yes` when a secret moves to a plain file. It cannot read an empty value from stdin.
272
+ - File tracing cannot see the `config/` directory, so include it in the function bundle, or `config()` fails at boot. In `vercel.json`: `{ "functions": { "api/**/*.js": { "includeFiles": "config/**" } } }`. With Next.js, use `outputFileTracingIncludes: { '/*': ['./config/**/*'] }` in `next.config.js`.
273
+
274
+ </details>
275
+
276
+ <details>
277
+ <summary data-logo="netlify"><strong>Netlify</strong></summary>
278
+
279
+ ```bash
280
+ $ secrets=$(npx onesecret dotenv --env production --json)
281
+ $ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
282
+ netlify env:set --context production --secret --force -- "$name" "$(printf %s "$secrets" | jq -r --arg name "$name" '.[$name]')"
283
+ done
284
+ $ netlify deploy --prod
285
+ ```
286
+
287
+ - `env:set` takes the value as an argument, so `$(...)` drops a trailing newline that belongs to the value. Keep the `--` and put the flags before it: a PEM key starts with `-`.
288
+ - Do not use `netlify env:import`: it writes every variable to all contexts without the secret flag, and prints the values.
289
+ - `env:set` adds and updates but never removes: run `netlify env:unset NAME --context production`. New values apply to the next deploy.
290
+ - Include the config files in the functions bundle, in `netlify.toml`: `[functions]` with `included_files = ["config/**"]`.
291
+
292
+ </details>
293
+
294
+ <details>
295
+ <summary data-logo="cloudflare-workers"><strong>Cloudflare Workers</strong></summary>
296
+
297
+ ```bash
298
+ $ secrets=$(npx onesecret dotenv --env production --json) &&
299
+ printf %s "$secrets" | npx wrangler secret bulk --name my-worker
300
+ ```
301
+
302
+ - `wrangler secret bulk` creates and deploys a new version of the Worker right away. It adds and updates but never removes: run `wrangler secret delete NAME`. It takes at most 100 secrets per call, and a Worker holds 128 variables in total on the Paid plan, 64 on Free.
303
+ - A Worker has no project directory at runtime. Attach the config files as modules and point `config()` at them:
304
+
305
+ ```toml
306
+ # wrangler.toml
307
+ compatibility_date = "2025-10-01"
308
+ compatibility_flags = ["nodejs_compat"]
309
+ base_dir = "."
310
+ find_additional_modules = true
311
+ rules = [{ type = "Text", globs = ["config/**/*.yml"], fallthrough = true }]
312
+ ```
313
+
314
+ ```js
315
+ const config = require('onesecret').config({ env: 'production', cwd: '/bundle' })
316
+ ```
317
+
318
+ - `package.json` is not in the bundle, so pass `dir` and `keyCommand` in code. The Workers runtime reports Node.js 22: onesecret ran there in `wrangler dev`, but it is below the supported `engines` range.
319
+
320
+ </details>
321
+
322
+ <details>
323
+ <summary data-logo="railway"><strong>Railway</strong></summary>
324
+
325
+ ```bash
326
+ $ secrets=$(npx onesecret dotenv --env production --json)
327
+ $ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
328
+ printf %s "$secrets" | jq -j --arg name "$name" '.[$name]' |
329
+ railway variable set "$name" --stdin --service api --environment production --skip-deploys
330
+ done
331
+ $ railway redeploy --service api --environment production --yes
332
+ ```
333
+
334
+ - `--stdin` strips trailing newlines from the value and refuses an empty one.
335
+ - Railway reads `${{ NAME }}` in a value as a reference to another variable.
336
+ - Do not paste `onesecret dotenv` into `railway variable edit`: it splits multi-line values, keeps backticks as part of the value, and deletes every variable missing from the file.
337
+ - `variable set` adds and updates but never removes: run `railway variable delete NAME`.
338
+
339
+ </details>
340
+
341
+ <details>
342
+ <summary data-logo="render"><strong>Render</strong></summary>
343
+
344
+ The Render CLI cannot set variables on an existing service, so the loop calls the API, one key at a time:
345
+
346
+ ```bash
347
+ $ secrets=$(npx onesecret dotenv --env production --json)
348
+ $ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
349
+ printf %s "$secrets" | jq --arg name "$name" '{value: .[$name]}' |
350
+ curl -fsS -o /dev/null -X PUT "https://api.render.com/v1/services/$RENDER_SERVICE_ID/env-vars/$name" \
351
+ -H "Authorization: Bearer $RENDER_API_KEY" -H 'Content-Type: application/json' --data-binary @-
352
+ done
353
+ $ render deploys create "$RENDER_SERVICE_ID" --wait --confirm
354
+ ```
355
+
356
+ - Each call adds or updates one variable set on the service, not in a linked environment group.
357
+ - Do not use the bulk `PUT /env-vars` endpoint: it replaces every variable, removing the plain ones set in the dashboard.
358
+
359
+ </details>
360
+
361
+ <details>
362
+ <summary data-logo="aws"><strong>AWS</strong></summary>
363
+
364
+ On ECS, keep every secret of an environment in one Secrets Manager secret as JSON, and reference each key from the task definition:
365
+
366
+ ```bash
367
+ $ secrets=$(npx onesecret dotenv --env production --json) &&
368
+ printf %s "$secrets" | aws secretsmanager put-secret-value --secret-id my-app/production --secret-string file:///dev/stdin
369
+ $ arn=$(aws secretsmanager describe-secret --secret-id my-app/production --query ARN --output text)
370
+ $ npx onesecret ls --env production | jq -Rn --arg arn "$arn" '[inputs | {name: ., valueFrom: "\($arn):\(.)::"}]'
371
+ ```
372
+
373
+ Put the printed list in `containerDefinitions[].secrets`, register the task definition, then roll the service, since tasks read secrets only when they start:
374
+
375
+ ```bash
376
+ $ aws ecs update-service --cluster prod --service my-app --force-new-deployment
377
+ ```
378
+
379
+ - The first time, run `create-secret --name my-app/production` in place of `put-secret-value --secret-id my-app/production`.
380
+ - `put-secret-value` replaces the whole JSON, so a removed secret disappears. Generate the task definition list again whenever the names change. The task execution role needs `secretsmanager:GetSecretValue`, and the JSON is limited to 64 KB.
381
+ - Do not use the task definition's `environmentFiles`: ECS keeps quotes as part of the value and cannot read multi-line values.
382
+ - On Lambda, `secrets=$(npx onesecret dotenv --env production --json) && printf %s "$secrets" | jq '{Variables: .}' | aws lambda update-function-configuration --function-name my-fn --environment file:///dev/stdin` sets them, but it replaces every variable of the function, and all of them together are limited to 4 KB. Ship `config/` inside the deployment package.
154
383
 
155
- Secrets are staged and applied by the next deploy. Pass `--no-stage` to restart the machines with them right away. Every value is encoded so that `fly secrets import` reads it back unchanged, including `#`, quotes, and multi-line values such as PEM keys. onesecret checks this against a port of flyctl's own parser before sending anything, and refuses the rare value that cannot round-trip (for example a line over 64 KiB, which flyctl drops). Keep secret names out of the `[env]` section of `fly.toml`, since those values would be stored as plain configuration. `fly secrets import` adds and updates but never removes, so when a secret moves to a plain file, run `fly secrets unset NAME`, or the old secret keeps overriding the file.
384
+ </details>
156
385
 
157
- ### Anywhere else
386
+ <details>
387
+ <summary data-logo="google-cloud-run"><strong>Google Cloud Run</strong></summary>
388
+
389
+ Keep each secret in Secret Manager and mount them all as variables. Secret names are shared by the whole Google Cloud project, so prefix them with the app and the environment:
390
+
391
+ ```bash
392
+ $ secrets=$(npx onesecret dotenv --env production --json)
393
+ $ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
394
+ id="my-app-production-$name"
395
+ gcloud secrets describe "$id" >/dev/null 2>&1 || gcloud secrets create "$id" --replication-policy=automatic
396
+ printf %s "$secrets" | jq -j --arg name "$name" '.[$name]' | gcloud secrets versions add "$id" --data-file=-
397
+ done
398
+ $ gcloud run deploy my-app --source . --set-secrets="$(npx onesecret ls --env production | sed 's/.*/&=my-app-production-&:latest/' | paste -sd, -)"
399
+ ```
400
+
401
+ - `--set-secrets` replaces the secrets of the previous revision. Values are read when an instance starts, so deploy after every push. The service account needs `roles/secretmanager.secretAccessor`.
402
+ - `--env-vars-file` stores values as plain configuration instead. If you use it, pass `onesecret dotenv --json > env.json`, never a `.env` file: gcloud reads those with python-dotenv, which expands `${NAME}` and breaks backtick quoting.
403
+ - With `--source`, keep `config/` out of `.gcloudignore`.
404
+
405
+ </details>
406
+
407
+ <details>
408
+ <summary data-logo="docker"><strong>Docker</strong></summary>
409
+
410
+ ```bash
411
+ $ npx onesecret exec --env production -- docker run $(npx onesecret ls --env production | sed 's/^/-e /') my-image
412
+ ```
413
+
414
+ `-e NAME` without a value copies `NAME` from the environment `onesecret exec` builds. The unquoted `$(...)` splits into one `-e NAME` pair per secret, which is safe because secret names are valid variable names. With Compose, list the names without values under `environment:`, such as `- DATABASE_URL`, then run `npx onesecret exec --env production -- docker compose up -d`.
415
+
416
+ - Do not pass `onesecret dotenv` output to `docker run --env-file` or Compose `env_file`. Docker keeps quotes as part of the value and fails on multi-line values, and Compose expands `$NAME` and rejects backticks.
417
+ - Copy the config files into the image, with `COPY config ./config` on a `node:24` base image.
418
+
419
+ </details>
420
+
421
+ <details>
422
+ <summary data-logo="github-actions"><strong>GitHub Actions</strong></summary>
423
+
424
+ A job that deploys needs a single secret: the age key. onesecret decrypts the rest in the job.
425
+
426
+ ```bash
427
+ $ printf %s "$SOPS_AGE_KEY" | gh secret set SOPS_AGE_KEY --env production
428
+ ```
429
+
430
+ ```yaml
431
+ jobs:
432
+ deploy:
433
+ runs-on: ubuntu-latest
434
+ environment: production
435
+ steps:
436
+ - uses: actions/checkout@v4
437
+ - run: ./deploy.sh
438
+ env:
439
+ SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
440
+ ```
441
+
442
+ The job must declare `environment: production`: GitHub gives an environment's secrets only to jobs that reference it.
443
+
444
+ Do not import `onesecret dotenv` with `gh secret set --env-file`: gh parses it with godotenv, which expands `$NAME` and rejects backticks.
445
+
446
+ </details>
447
+
448
+ <details>
449
+ <summary data-logo="anywhere"><strong>Anywhere else</strong></summary>
158
450
 
159
451
  ```bash
160
452
  $ npx onesecret dotenv --env production > .env.production
@@ -162,19 +454,103 @@ $ npx onesecret dotenv --env production > .env.production
162
454
 
163
455
  Values are quoted only when needed, and every line is checked against both Node.js's own `.env` parser and the `dotenv` package before it is printed, so `#`, spaces, quotes, backslashes, and newlines read back unchanged with `node --env-file` or `dotenv`. A value that no quoting can carry through both, such as one starting with `'` and ending with `\`, is refused instead of corrupted.
164
456
 
165
- Keep the `config/` directory in your image: `config()` reads the plain settings from it and the secret names to check that the platform injected every secret, and fails at boot if one is missing.
457
+ Other `.env` parsers differ, as the providers above show. When a tool takes JSON, prefer `onesecret dotenv --json`, which carries every value unchanged, as a string.
458
+
459
+ </details>
460
+
461
+ ## How it works
462
+
463
+ ### Your config is a folder of files
464
+
465
+ Each environment can have two files, one for plain settings and one for secrets. Every file is optional.
466
+
467
+ ```
468
+ config/default.yml ← settings for every environment
469
+ config/default.enc.yml ← secrets for every environment
470
+ config/production.yml ← settings for production only
471
+ config/production.enc.yml ← secrets for production only
472
+ ```
473
+
474
+ onesecret reads them in that order and merges them, so a later file wins. Nested settings are merged key by key, so `production.yml` can change `database.pool` and keep the rest of `database` from `default.yml`.
475
+
476
+ In the encrypted files, only the values are encrypted. The names stay readable, which is how onesecret knows what should exist without the key, and how `npx onesecret ls` lists them.
477
+
478
+ The encrypted files are standard [SOPS](https://github.com/getsops/sops) files locked with an [age](https://age-encryption.org) key, so the `sops` CLI and other SOPS tools can read them too. You don't need to install either: onesecret does the encryption itself.
479
+
480
+ ### What happens when your app starts
481
+
482
+ `config()` and `env()` look at the secret names, then at what is already in the environment:
483
+
484
+ ```
485
+ Is every secret already set in the environment?
486
+ ├─ yes → use those values, decrypt nothing ← production
487
+ └─ no → is there a key?
488
+ ├─ yes → decrypt only the missing ones ← development, CI
489
+ └─ no → stop, naming each missing secret
490
+ ```
491
+
492
+ - A value already in the environment always wins, for plain settings too. An empty string counts as set.
493
+ - To fill in missing secrets, onesecret decrypts every encrypted file of that environment, so the key must open all of them, even when only one holds a missing secret.
494
+ - `config()` never changes `process.env`. `env()` changes nothing but `process.env`, or `options.processEnv` when given.
495
+
496
+ ### Where the key comes from
497
+
498
+ onesecret looks for the key in these places and tries every key it finds:
499
+
500
+ 1. `SOPS_AGE_KEY`, the key itself. This is the usual choice in CI.
501
+ 2. `SOPS_AGE_KEY_FILE`, a path to a keys file.
502
+ 3. The keys file the `sops` CLI uses: `$XDG_CONFIG_HOME/sops/age/keys.txt`, `~/Library/Application Support/sops/age/keys.txt` on macOS, or `~/.config/sops/age/keys.txt`.
503
+
504
+ If none of them opens the file, it runs `SOPS_AGE_KEY_CMD` or the `keyCommand` setting.
505
+
506
+ ### Fetch the key with a command
507
+
508
+ With `keyCommand`, nobody keeps the key on disk. It is a command that prints the key, for example from a GitLab CI variable (`glab`) or from 1Password (`op`). Set it in `package.json`:
509
+
510
+ ```json
511
+ {
512
+ "onesecret": {
513
+ "keyCommand": "glab variable get SOPS_AGE_KEY -R my-group/my-project"
514
+ }
515
+ }
516
+ ```
517
+
518
+ It runs at most once per process, whether it works or not. `config()` and `env()` never run it in `production` or `staging`, so a missing secret there fails the boot instead of fetching a key. The CLI (`exec`, `get`, `dotenv`) runs it in any environment, since you call it on purpose.
519
+
520
+ Because it comes from `package.json`, `keyCommand` is as trusted as an npm script: only run onesecret in repositories you trust.
166
521
 
167
- ## Tests
522
+ ### Keeping the key away from your app
168
523
 
169
- Unit tests usually do not need real credentials. Set `ONESECRET_SKIP=1` and `config()` never decrypts: it still loads the plain settings, and leaves unset any secret the environment does not provide.
524
+ `exec` removes `SOPS_AGE_KEY`, `SOPS_AGE_KEY_FILE`, and `SOPS_AGE_KEY_CMD` before it starts your command, so the command gets the secrets but not the key.
525
+
526
+ Two things still see it: the keys file stays readable on disk to anything running as you, and `edit` and `keyCommand` run with your full environment.
527
+
528
+ ### New environments use the same key
529
+
530
+ A new encrypted file, such as `config/staging.enc.yml`, is locked for the same key as `config/default.enc.yml` in its folder, with the same encryption rules. If the folder has no `default.enc.yml` yet, it is locked for the key you exported in `SOPS_AGE_KEY`.
531
+
532
+ ### Separate keys per person
533
+
534
+ One shared key needs no setup. To give each person or CI job its own key, list their public keys in a `.sops.yaml` rule, the format the `sops` CLI uses, then encrypt each environment again:
535
+
536
+ ```yaml
537
+ # .sops.yaml
538
+ creation_rules:
539
+ - path_regex: ^config/
540
+ age: age1…alice,age1…bob,age1…ci
541
+ ```
170
542
 
171
543
  ```bash
172
- $ ONESECRET_SKIP=1 npm test
544
+ $ npx onesecret rekey
545
+ $ npx onesecret rekey --env production
173
546
  ```
174
547
 
175
- Integration tests that need credentials can run under `onesecret exec -- ava`, which decrypts once and hands the values, never the key, to every test worker.
548
+ When a `.sops.yaml` rule matches a file, its keys replace the ones the file already lists. Removing someone stops them from reading future versions, but not the versions they already had, so change the credentials themselves too.
549
+
550
+ ## Reference
176
551
 
177
- ## CLI
552
+ <details>
553
+ <summary><strong>CLI</strong></summary>
178
554
 
179
555
  ```
180
556
  $ onesecret --help
@@ -185,66 +561,62 @@ Usage
185
561
  Commands
186
562
  init Write config/default.enc.yml for a new age key it prints once,
187
563
  or for SOPS_AGE_KEY when exported
188
- set NAME=value... Encrypt one or more secrets into a layer
564
+ set NAME Encrypt a secret into a layer, typed in $EDITOR
565
+ (or read from stdin when piped)
189
566
  unset NAME... Remove secrets from a layer
190
567
  edit [file] Open an encrypted file decrypted in $EDITOR, encrypt it on save
191
568
  (a plain .yml or .json file opens as it is)
192
569
  rekey Encrypt a layer again with a new data key, for its recipients or a .sops.yaml rule
193
570
  ls List secret names (no key needed)
194
571
  get NAME Print one value, from a plain or an encrypted layer
572
+ (with no trailing newline when piped)
195
573
  dotenv Print every decrypted secret as NAME=value
574
+ (--json prints them as one JSON object)
196
575
  exec -- <command> Run a command with the config and secrets in its environment
197
576
  (--env also sets its NODE_ENV)
198
- kubernetes --name <name> Apply the secrets as a Kubernetes Secret, print its checksum
199
- fly --app <app> Import the secrets into a Fly app, print their checksum
200
577
 
201
578
  Options
202
579
  --env, -e Layer to read on top of "default" (defaults to NODE_ENV, then "development").
203
580
  Write commands (set, unset, edit, rekey) write this layer, "default" when omitted.
204
- kubernetes and fly require it, and the layer file must exist ("default" deploys the base alone).
581
+ Passed to dotenv or ls, the layer file must exist.
205
582
  --dir Directory holding the config files (defaults to "config")
206
583
  --cwd Project root, where package.json and the config directory are (defaults to the current directory)
207
- --namespace, -n Kubernetes namespace
208
- --context kubectl context
209
- --no-stage fly: apply the secrets now instead of on the next deploy
584
+ --json dotenv: print a JSON object instead of NAME=value lines
210
585
 
211
586
  Examples
212
587
  $ onesecret init
213
- $ onesecret set DATABASE_URL=postgres://localhost/app
214
- $ onesecret set --env production DATABASE_URL=postgres://db.internal/app
588
+ $ onesecret set DATABASE_URL
589
+ $ printf %s "$TOKEN" | onesecret set --env production API_TOKEN
215
590
  $ onesecret edit config/production.enc.yml
216
591
  $ onesecret exec -- node server.js
217
- $ onesecret kubernetes --env production --name api-env --namespace api
218
- $ onesecret fly --env production --app my-app
592
+ $ onesecret dotenv --env production --json
219
593
  ```
220
594
 
221
- `set`, and `edit` of an encrypted file, only accept names that are valid environment variables, refuse `sops` (reserved for the file metadata), and refuse to write a value that the file's rules would leave in plaintext. A name ending in `_unencrypted` is stored in plaintext on purpose, as sops does.
595
+ **set**
222
596
 
223
- When an edit is not valid YAML, the editor opens again with your changes, and the error never prints the decrypted content.
597
+ In a terminal, `set NAME` opens `$EDITOR` on the current value: the newline the editor adds at the end is dropped, and saving an empty file cancels. When stdin is piped, `set` waits until it closes and stores the bytes as they are, so pipe with `printf %s` rather than `echo`, which adds a newline. It refuses an empty value, text that is not UTF-8, and NUL bytes; to store an empty value, use `onesecret edit`. Values never go on the command line, so they stay out of shell history and the process list.
224
598
 
225
- `edit` opens `$VISUAL` or `$EDITOR` (then `vi`). GUI editors that return at once, such as VS Code, Cursor, Zed, or Sublime Text, get their wait flag added, so `EDITOR=code` works without `--wait`. Any other editor command must wait until the file is closed, as git requires too. It takes the path of the file to open, such as `config/production.enc.yml`, or edits the `--env` layer when no path is given. A path that does not exist yet is locked for the recipients of `default.enc.yml` in its folder, or else for your exported `SOPS_AGE_KEY`, and is refused before the editor opens when there is neither. A plain `.yml` or `.json` path opens as it is; when it is saved invalid, the editor opens again. `get` reads a plain value without the key, and prints a nested value as YAML.
599
+ `set`, and `edit` of an encrypted file, only accept names that are valid environment variables, refuse `sops` (reserved for the file metadata), and refuse to write a value that the file's rules would leave in plaintext. A name ending in `_unencrypted` is stored in plaintext on purpose, as sops does.
226
600
 
227
- ### Separate keys per person
601
+ **edit**
228
602
 
229
- One shared key needs no configuration. To give each person or CI its own key instead, list their public keys in a `.sops.yaml` rule, the format the `sops` CLI uses, then encrypt each layer again:
603
+ `edit` takes the path of the file to open, such as `config/production.enc.yml`, or edits the `--env` layer when no path is given.
230
604
 
231
- ```yaml
232
- # .sops.yaml
233
- creation_rules:
234
- - path_regex: ^config/
235
- age: age1…alice,age1…bob,age1…ci
236
- ```
605
+ - **Editor.** It opens `$VISUAL` or `$EDITOR` (then `vi`). GUI editors that return at once, such as VS Code, Cursor, Zed, or Sublime Text, get their wait flag added, so `EDITOR=code` works without `--wait`. Any other editor command must wait until the file is closed, as git requires too.
606
+ - **Invalid YAML.** When an edit is not valid YAML, the editor opens again with your changes, and the error never prints the decrypted content.
607
+ - **New files.** A path that does not exist yet is locked for the recipients of `default.enc.yml` in its folder, or else for your exported `SOPS_AGE_KEY`, and is refused before the editor opens when there is neither.
608
+ - **Plain files.** A plain `.yml` or `.json` path opens as it is; when it is saved invalid, the editor opens again.
237
609
 
238
- ```bash
239
- $ npx onesecret rekey
240
- $ npx onesecret rekey --env production
241
- ```
610
+ **get**
611
+
612
+ `get` reads a plain value without the key, and prints a nested value as YAML. On a terminal it ends the value with a newline; piped, it prints the value alone, so `onesecret get NAME | some-cli --stdin` stores exactly the value. A `while read` loop over it needs `|| [ -n "$line" ]` to keep the last line.
242
613
 
243
- When a `.sops.yaml` rule matches a file, its recipients win over the ones the file already lists. Removing someone stops them from reading future versions, but they can still read every version they already had. Rotate the credentials themselves too.
614
+ </details>
244
615
 
245
- ## API
616
+ <details>
617
+ <summary><strong>API: options</strong></summary>
246
618
 
247
- `config`, `load`, and `names` take the env name as a string, or these options, which `kubernetes` and `fly` take too:
619
+ `config` and `env` take the env name as a string, or these options:
248
620
 
249
621
  | Option | Default | Description |
250
622
  | ------------ | ---------------------------------- | ----------------------------------------------------- |
@@ -252,60 +624,133 @@ When a `.sops.yaml` rule matches a file, its recipients win over the ones the fi
252
624
  | `dir` | `"config"` | Directory with the config files, relative to `cwd`. |
253
625
  | `env` | `NODE_ENV`, then `"development"` | Layer merged on top of `default`. |
254
626
  | `keyCommand` | none | Command that prints the age key. |
255
- | `processEnv` | `process.env` | `config()` only: the environment to read and fill, including `NODE_ENV` and `ONESECRET_SKIP`. |
627
+ | `processEnv` | `process.env` | `config()` and `env()`: the environment to read, including `NODE_ENV` and `ONESECRET_SKIP`, and the one `env()` writes. |
256
628
 
257
629
  `dir` and `keyCommand` can also be set in `package.json` under `"onesecret"`.
258
630
 
259
- ### config([env | options])
631
+ </details>
260
632
 
261
- Merges the layers, fills the missing values into `process.env` (or `options.processEnv`), and returns the merged object, with the values the environment already had taking their place. Throws an `OnesecretError` with code `EMISSING` when a secret is missing and there is no key to decrypt it.
633
+ <details>
634
+ <summary><strong>API: config()</strong></summary>
635
+
636
+ Merges the layers and returns the merged object, with the values the environment already had taking their place. It never changes `process.env`. Throws an `OnesecretError` with code `EMISSING` when a secret is missing and there is no key to decrypt it.
262
637
 
263
638
  ```js
264
639
  const config = require('onesecret').config() // NODE_ENV, then "development"
265
640
  const staging = require('onesecret').config('staging')
266
641
  ```
267
642
 
268
- ### load([env | options])
643
+ The object also reads values through four accessors:
644
+
645
+ ```js
646
+ config.DATABASE_URL // plain access
647
+ config.get('database.pool', 5) // a dot path, with a fallback when it is unset
648
+ config.has('FEATURE_FLAG') // true when set, even to 0, false, or ''
649
+ config.require('STRIPE_KEY') // the value, or throws EMISSING when it is unset
650
+ const { STRIPE_KEY, DATABASE_URL } = config.required // throws for an unset name
651
+ ```
269
652
 
270
- Returns every layer merged and decrypted, ignoring `process.env`. Values keep their YAML type.
653
+ - A path is a dot string or an array of keys, such as `['database', 'pool']`. A string matches an exact name first, so `get('log.level')` reads a key named `log.level` before it looks into `log`. Only config values count: `has('constructor')`, `has('get')`, and `has('hosts.length')` are `false`.
654
+ - The accessors are not enumerable, so they stay out of `Object.keys`, spread, and `JSON.stringify`. A config value named `get`, `has`, `require`, or `required` is refused.
655
+ - `config.required` is only for destructuring: every name read from it is required, so `JSON.stringify(config.required)` or `await config.required` throws.
656
+ - They work like [miconfig](https://github.com/Kikobeats/miconfig)'s with two differences. `0`, `false`, `''`, and `null` count as set. Values are not parsed again: they keep their YAML type, and a value from the environment is a string unless it matches the file's value, so `PORT=8080` over `PORT: 3000` reads `'8080'`.
271
657
 
272
- ### names([env | options])
658
+ </details>
273
659
 
274
- Returns the secret names, without needing the key.
660
+ <details>
661
+ <summary><strong>API: env()</strong></summary>
275
662
 
276
- ### kubernetes({ name, namespace, context, args, kubectl, ...options })
663
+ Writes every value missing from `process.env` (or `options.processEnv`) into it, as a string: the same values `config()` would add on top of the environment. It follows the same rules and throws the same errors as `config()`, writes nothing when it throws, and returns nothing.
277
664
 
278
- Applies the secrets as an `Opaque` Secret with `kubectl apply`. Returns `{ checksum, output }`.
665
+ ```js
666
+ require('onesecret').env()
667
+ ```
279
668
 
280
- ### fly({ app, stage = true, flyctl, ...options })
669
+ </details>
281
670
 
282
- Imports the secrets with `fly secrets import`. Returns `{ checksum, output }`.
671
+ <details>
672
+ <summary><strong>API: helpers</strong></summary>
283
673
 
284
- ### toEnv(secrets), toDotenv(secrets), checksum(secrets), withoutAgeKey(env)
674
+ Helpers: turn secrets into environment strings, render them as dotenv, hash them in an order-independent way, and drop the age key variables from an environment.
285
675
 
286
- Helpers used by the commands above: turn secrets into environment strings, render them as dotenv, hash them in an order-independent way, and drop the age key variables from an environment.
676
+ </details>
287
677
 
288
- ### Errors
678
+ <details>
679
+ <summary><strong>Errors</strong></summary>
289
680
 
290
681
  Every error is an `OnesecretError` with a `code`:
291
682
 
292
683
  | Code | Meaning |
293
684
  | ----------- | ------------------------------------------------------------------ |
294
- | `EMISSING` | A secret is not in the environment and could not be decrypted. |
685
+ | `EMISSING` | A secret is not in the environment and could not be decrypted, or `config().require` found no value. |
295
686
  | `ENOKEY` | No age key was found. |
296
687
  | `EKEY` | A key was found but does not open the file, or `keyCommand` failed. |
297
688
  | `EMAC` | The file was modified without the key. |
298
- | `EFORMAT` | A file cannot be read: not valid YAML or JSON, not a supported SOPS file, two files for one layer, or a secret `config()` could never find in the environment. |
689
+ | `EFORMAT` | A file cannot be read: not valid YAML or JSON, not a supported SOPS file, two files for one layer, a secret `config()` could never find in the environment, or a config name reserved for an accessor. |
299
690
  | `EVALUE` | A value cannot be stored or turned into an environment variable. |
300
- | `EDEPLOY` | `kubectl` or `fly` failed. |
301
691
  | `EUSAGE` | A CLI command was called the wrong way, or the editor failed. |
302
692
 
303
- ## Compatibility
693
+ </details>
694
+
695
+ <details>
696
+ <summary><strong>Secrets and plain settings</strong></summary>
697
+
698
+ A name is a secret while an encrypted file contributes to its merged value. A plain file that replaces a secret from a lower layer makes the name plain config for that env; a plain mapping merged into an encrypted one keeps it a secret.
699
+
700
+ A secret must be a string, number, or boolean, since the platform injects it as a variable: a mapping or list secret missing from the environment fails with `EFORMAT`.
701
+
702
+ </details>
703
+
704
+ <details>
705
+ <summary><strong>Types and process.env</strong></summary>
706
+
707
+ - `env()` writes every top-level value that is a string, number, boolean, or `null` into `process.env`, as a string.
708
+ - `config()` returns the merged object with the YAML types kept, so `TIMEOUT: 60000` comes back as the number `60000`, while `env()` sets `process.env.TIMEOUT` to `"60000"`.
709
+ - Nested objects and lists, and names that are not valid variable names (such as `log-level`), are returned by `config()` but never written into `process.env`.
710
+ - A value the environment provides comes back with its file's type when the string matches it, so `PORT=3000` is the number `3000` again, even for a secret, whose type is plaintext in the encrypted file.
711
+
712
+ </details>
304
713
 
305
- onesecret reads and writes SOPS files encrypted for age X25519 recipients, in YAML or JSON, and reads every value type sops writes in those formats (`str`, `int`, `float`, `bool`, `time`). Values in encrypted files resolve the way go-yaml v3 does in sops, so `0777` is 511 and `2024-01-01` is a timestamp. Plain files are read with js-yaml instead, where `0777` is 777, YAML merge keys (`<<`) are not applied, and integers beyond 2^53 lose precision, so moving a value between a plain and an encrypted file can change it. Integers beyond 2^53 come back as `BigInt`, and timestamps as RFC 3339 strings, which are written back as strings. Rules and `path_regex` accept Go regular expression syntax such as `(?i)`, `\A`, `\z`, and `[[:lower:]]`. The test suite checks all of this against `sops` 3.13 and `age` 1.3 in both directions, including `mac_only_encrypted` and the `*_suffix` and `*_regex` rules. Like sops, it rejects truncated authentication tags and low-order age recipients.
714
+ <details>
715
+ <summary><strong>File rules</strong></summary>
716
+
717
+ - **Formats.** Plain files are YAML (`.yml`, `.yaml`) or JSON (`.json`), read with [js-yaml](https://github.com/nodeca/js-yaml); an empty file is an empty layer.
718
+ - **One file of each kind per layer.** Each layer has at most one plain and one encrypted file, so `default.yml` next to `default.json` is an error, and so is a sops-encrypted file named like a plain one.
719
+ - **YAML tags.** Encrypted files accept only the standard tags `!!str`, `!!int`, `!!float`, `!!bool`, `!!null`, and `!!timestamp`, on values, so a value such as `!Passw0rd` must be quoted.
720
+ - **Errors never print secrets.** A YAML error names the line and column but never the text, so a broken line cannot print a secret.
721
+ - **Env names.** Env names are lowercase letters, digits, `.`, `_`, and `-`, without `..`, an `enc` part, or a file extension, so `NODE_ENV=Production` fails at boot instead of reading another file.
722
+ - **Exact file names.** Layer files must match that exact name: `Production.yml` next to a `production` layer is refused, so a layer reads the same on every filesystem.
723
+ - **No files at all.** `config()` throws when it finds no file at all, so an image that forgot to copy `config/`, or a process started from the wrong directory, fails at boot instead of running without its configuration.
724
+
725
+ </details>
726
+
727
+ <details>
728
+ <summary><strong>Key sources</strong></summary>
729
+
730
+ A source can hold several keys, one per line or separated by spaces. Post-quantum (`AGE-SECRET-KEY-PQ-`) and plugin identities are skipped, and SSH keys are not read. A source that cannot be read or parsed, such as a keys file holding an SSH key, is skipped when another source has a key; when no key opens the file, the error names the sources it skipped.
731
+
732
+ </details>
733
+
734
+ <details>
735
+ <summary><strong>Performance</strong></summary>
736
+
737
+ After about 9ms to load the module, the first `config()` call takes about 6ms when it decrypts two layers, against roughly 30ms for spawning `sops -d` twice, and about 3.5ms when the platform already injected every secret and nothing is decrypted. Each file is decrypted once per process, so later calls, including `env()` after `config()`, take about 1ms.
738
+
739
+ </details>
740
+
741
+ <details>
742
+ <summary><strong>Compatibility</strong></summary>
743
+
744
+ onesecret reads and writes SOPS files encrypted for age X25519 recipients, in YAML or JSON, and reads every value type sops writes in those formats (`str`, `int`, `float`, `bool`, `time`).
745
+
746
+ Values in encrypted files resolve the way go-yaml v3 does in sops, so `0777` is 511 and `2024-01-01` is a timestamp. Plain files are read with js-yaml instead, where `0777` is 777, YAML merge keys (`<<`) are not applied, and integers beyond 2^53 lose precision, so moving a value between a plain and an encrypted file can change it. Integers beyond 2^53 come back as `BigInt`, and timestamps as RFC 3339 strings, which are written back as strings.
747
+
748
+ Rules and `path_regex` accept Go regular expression syntax such as `(?i)`, `\A`, `\z`, and `[[:lower:]]`. The test suite checks all of this against `sops` 3.13 and `age` 1.3 in both directions, including `mac_only_encrypted` and the `*_suffix` and `*_regex` rules. Like sops, it rejects truncated authentication tags and low-order age recipients.
306
749
 
307
750
  It does not support PGP, cloud KMS, key groups, Shamir secret sharing, comment-based rules, or YAML anchors and multiple documents in an encrypted file (`.sops.yaml` can use anchors). It refuses to write a file that also has KMS or PGP keys rather than drop them, and YAML comments are lost when it writes a file. Use the `sops` CLI for any of those.
308
751
 
752
+ </details>
753
+
309
754
  ## License
310
755
 
311
756
  **onesecret** © [Jose Francisco 'Kiko' Verdú Gambín](https://kikobeats.com), released under the [MIT](https://github.com/kikobeats/onesecret/blob/master/LICENSE.md) License.<br>