@dotenvx/dotenvx 2.28.0 → 2.28.1

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/README.md +40 -8
  3. package/package.json +1 -1
  4. package/src/cli/actions/define.js +17 -0
  5. package/src/cli/actions/ext/precommit.js +3 -0
  6. package/src/cli/actions/ext/precommitClean.js +21 -0
  7. package/src/cli/actions/get.js +2 -1
  8. package/src/cli/actions/init.js +3 -0
  9. package/src/cli/actions/lock/down.js +13 -32
  10. package/src/cli/actions/lock/up.js +13 -30
  11. package/src/cli/actions/run.js +2 -1
  12. package/src/cli/commands/custody.js +2 -2
  13. package/src/cli/commands/ext.js +3 -1
  14. package/src/cli/commands/fileOptions.js +22 -0
  15. package/src/cli/dotenvx.js +38 -21
  16. package/src/lib/custodians/index.js +73 -0
  17. package/src/lib/{helpers/bitwardenCustody.js → custodians/local/bitwarden.js} +12 -7
  18. package/src/lib/custodians/local/file.js +18 -0
  19. package/src/lib/custodians/local/native/backend.js +62 -0
  20. package/src/lib/custodians/local/native/index.js +28 -0
  21. package/src/lib/{helpers/storeNativePrivateKey.js → custodians/local/native/store.js} +3 -3
  22. package/src/lib/{helpers/onePasswordCustody.js → custodians/local/onepassword.js} +9 -5
  23. package/src/lib/custodians/lock.js +80 -0
  24. package/src/lib/{providers/armor/index.js → custodians/managed/armor/get.js} +6 -6
  25. package/src/lib/custodians/managed/armor/index.js +24 -0
  26. package/src/lib/custodians/managed/armor/store.js +37 -0
  27. package/src/lib/helpers/installPrecommitFilter.js +50 -0
  28. package/src/lib/helpers/installPrecommitHook.js +2 -2
  29. package/src/lib/helpers/lockedValue.js +24 -0
  30. package/src/lib/helpers/macosKeychain.js +18 -2
  31. package/src/lib/helpers/matchesStoredKey.js +16 -0
  32. package/src/lib/helpers/normalizeDotenvConfigPath.js +1 -1
  33. package/src/lib/helpers/parseWithDecryptor.js +3 -0
  34. package/src/lib/helpers/prompts.js +18 -0
  35. package/src/lib/helpers/resolveLockPassword.js +5 -0
  36. package/src/lib/helpers/selectKeyStorage.js +9 -21
  37. package/src/lib/helpers/unlockedValue.js +26 -0
  38. package/src/lib/helpers/withLockedKeys.js +41 -0
  39. package/src/lib/main.d.ts +6 -0
  40. package/src/lib/main.js +4 -1
  41. package/src/lib/providers/index.js +9 -66
  42. package/src/lib/providers/native/index.js +2 -62
  43. package/src/lib/providers/provider-worker.js +1 -1
  44. package/src/lib/resolvers/envs.js +14 -5
  45. package/src/lib/resolvers/get.js +1 -0
  46. package/src/lib/services/custodyTransfer.js +2 -2
  47. package/src/lib/services/init.js +49 -0
  48. package/src/lib/services/precommit.js +12 -1
  49. package/src/lib/services/validate.js +1 -0
  50. package/src/lib/transforms/encrypt.js +6 -49
  51. package/src/lib/transforms/set.js +6 -49
package/CHANGELOG.md CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
- [Unreleased](https://github.com/dotenvx/dotenvx/compare/v2.28.0...main)
5
+ [Unreleased](https://github.com/dotenvx/dotenvx/compare/v2.28.1...main)
6
+
7
+ ## [2.28.1](https://github.com/dotenvx/dotenvx/compare/v2.28.0...v2.28.1) (2026-09-19)
8
+
9
+ ### Changed
10
+
11
+ * Send pk via stdin to keychain. was argv. thank you @kta1kri for the security report. ([#978](https://github.com/dotenvx/dotenvx/pull/978))
12
+ * Move some commands under a hidden menu ([#973](https://github.com/dotenvx/dotenvx/pull/973))
6
13
 
7
14
  ## [2.28.0](https://github.com/dotenvx/dotenvx/compare/v2.27.0...v2.28.0) (2026-09-16)
8
15
 
package/README.md CHANGED
@@ -1599,7 +1599,7 @@ end
1599
1599
 
1600
1600
  Both `dotenvx run -f .env.production -- node index.js` and `dotenvx validate -f .env.production` use the production rules. Block declarations inherit top-level options and override only the options they specify. A block's `encrypted` directive applies to all inherited and newly declared variables; a per-variable `encrypted:` option inside that block overrides it.
1601
1601
 
1602
- Paths in file blocks are relative to the Envfile. They match the selected paths exactly after path normalization (`./.env.production` matches `.env.production`); they are not basename matches or globs. Directory inputs and `DOTENV_PATH` use their resolved file paths.
1602
+ Paths in file blocks are relative to the Envfile. They match the selected paths exactly after path normalization (`./.env.production` matches `.env.production`); they are not basename matches or globs. Directory inputs and `DOTENV_FILE` use their resolved file paths.
1603
1603
 
1604
1604
  When no file block matches, top-level rules apply. When one or more blocks match, each matching block's inherited rules must hold for the final resolved environment. Shell values, fallback files, and `--overload` cannot bypass them. A selected missing file still activates its block. Multiple matching blocks cannot cancel each other's restrictions; conflicting proxy domains are rejected. Blocks cannot be nested, and duplicate declarations within one scope or duplicate file blocks are errors.
1605
1605
 
@@ -2790,6 +2790,26 @@ $ dotenvx ls --json
2790
2790
  $ dotenvx ls --json > dotenv-files.json
2791
2791
  ```
2792
2792
 
2793
+ </details>
2794
+ <details><summary>`define` (hidden)</summary><br>
2795
+
2796
+ Create an `Envfile` to validate your project's environment variables:
2797
+
2798
+ ```sh
2799
+ $ dotenvx define
2800
+ ≡ defined (Envfile)
2801
+ ```
2802
+
2803
+ Merges variable names from both `.env.example` and `.env`, skipping missing files and including each name once. Use `dotenvx define -f .env.production` to read only a specific input. If neither default file exists, it creates a starter. An existing `Envfile` is always left unchanged. The hidden `dotenvx init` command currently performs the same step.
2804
+
2805
+ The first line is `encrypted false` by default, or `encrypted true` if any application variable assignment in either input starts with `encrypted:`. Duplicate assignments are all inspected. This sets the encryption requirement for all declarations; use `encrypted: false` on individual variables that should remain plaintext.
2806
+
2807
+ Generated declarations are required by default. Review them and mark optional variables with `optional: true`.
2808
+
2809
+ Only names are copied: values are never included, decrypted, expanded, or fetched from secret providers. Dotenvx public and private key entries are excluded. Your env files are unchanged.
2810
+
2811
+ Run `dotenvx validate` (or `dotenvx validate -f .env.production` for another file) to check your configuration. Once `Envfile` exists, `dotenvx run` validates automatically before starting your command.
2812
+
2793
2813
  </details>
2794
2814
  <details><summary>`validate`</summary><br>
2795
2815
 
@@ -2914,11 +2934,22 @@ $ dotenvx precommit
2914
2934
  </details>
2915
2935
  <details><summary>`precommit --install`</summary><br>
2916
2936
 
2917
- Install a shell script to `.git/hooks/pre-commit` to prevent accidentally committing any `.env` files to source control.
2937
+ Install a pre-commit hook and a required Git clean filter in the current repository. The filter rejects plaintext `.env*` files before staging, including with `git add -A` or `git add -f`. Encrypted files pass through unchanged. `.env.example`, `.env.vault`, and `.env.x` retain their exemptions; `.env.keys*` files are always rejected.
2938
+
2939
+ The filter uses repository-local Git configuration and `info/attributes`; run the installer in each clone. Keep the installed dotenvx executable available, or staging protected files will fail. It does not inspect content already staged before installation, and local Git configuration can be overridden.
2940
+
2941
+ To enable the clean filter for all existing and future repositories for your user, run this once, even outside a Git repository:
2942
+
2943
+ ```sh
2944
+ $ dotenvx precommit --install --global
2945
+ ```
2946
+
2947
+ This writes the filter configuration to your global Git config and adds the `.env*` rule to your global attributes file. It preserves an existing `core.attributesFile`; otherwise it uses `$XDG_CONFIG_HOME/git/attributes` or `~/.config/git/attributes`. It leaves hooks unchanged. Repository attributes and configuration can override the global protection. Use a persistent dotenvx installation: moving or removing its executable requires reinstalling the filter.
2918
2948
 
2919
2949
  ```sh
2920
2950
  $ dotenvx precommit --install
2921
2951
  ▣ dotenvx precommit installed [.git/hooks/pre-commit]
2952
+ ▣ dotenvx required clean filter installed (blocks staging plaintext .env files)
2922
2953
  ```
2923
2954
 
2924
2955
  </details>
@@ -3249,7 +3280,7 @@ inject env at runtime [dotenvx run -- yourcommand]
3249
3280
 
3250
3281
  Options:
3251
3282
  -e, --env <strings...> environment variable(s) set as string (example: "HELLO=World") (default: [])
3252
- -f, --env-file <paths...> path(s) to your env file(s) (default: [])
3283
+ -f, --file <paths...> path(s) to your env file(s) (default: [])
3253
3284
  -fv, --env-vault-file <paths...> path(s) to your .env.vault file(s) (default: [])
3254
3285
  -o, --overload override existing env variables
3255
3286
  --convention <name> load a .env convention (available conventions: ['nextjs'])
@@ -3747,8 +3778,9 @@ There are global settings available that can be configured as environment variab
3747
3778
  ```ini
3748
3779
  # config
3749
3780
  DOTENV_CONVENTION= # set to a default convention like 'nextjs' or 'flow'
3750
- DOTENV_PATH= # path to your env file; comma-separate multiple paths
3751
- DOTENV_F= # synonym for DOTENV_PATH
3781
+ DOTENV_FILE= # path to your env file; comma-separate multiple paths
3782
+ DOTENV_PATH= # synonym for DOTENV_FILE
3783
+ DOTENV_F= # synonym for DOTENV_FILE
3752
3784
  DOTENV_IGNORE= # MISSING_ENV_FILE,OTHER
3753
3785
  DOTENV_QUIET= # set to "true" to default to --quiet
3754
3786
 
@@ -3872,13 +3904,13 @@ Breaking this encryption would require brute-forcing both AES-256 and elliptic c
3872
3904
 
3873
3905
  You are using Node 20 or greater and it adds a differing implementation of `--env-file` flag support. Rather than warn on a missing `.env` file (like dotenv has historically done), it raises an error: `node: .env: not found`.
3874
3906
 
3875
- This fix is easy. Replace `--env-file` with `-f`.
3907
+ This fix is easy. Replace `--env-file` with `--file` (or `-f`).
3876
3908
 
3877
3909
  ```bash
3878
- # from this:
3910
+ # from this (legacy spelling):
3879
3911
  ./node_modules/.bin/dotenvx run --env-file .env -- yourcommand
3880
3912
  # to this:
3881
- ./node_modules/.bin/dotenvx run -f .env -- yourcommand
3913
+ ./node_modules/.bin/dotenvx run --file .env -- yourcommand
3882
3914
  ```
3883
3915
 
3884
3916
  [more context](https://github.com/dotenvx/dotenvx/issues/131)
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.28.0",
2
+ "version": "2.28.1",
3
3
  "name": "@dotenvx/dotenvx",
4
4
  "description": "a secure dotenv–from the creator of `dotenv`",
5
5
  "author": "@motdotla",
@@ -0,0 +1,17 @@
1
+ const initEnvfile = require('../../lib/services/init')
2
+ const { logger } = require('../../shared/logger')
3
+ const catchAndLog = require('../../lib/helpers/catchAndLog')
4
+
5
+ module.exports = function define () {
6
+ try {
7
+ const { created } = initEnvfile({ envFile: this.opts().envFile })
8
+ if (created) {
9
+ logger.success('≡ defined (Envfile)')
10
+ } else {
11
+ logger.info('○ Envfile already exists (unchanged)')
12
+ }
13
+ } catch (error) {
14
+ catchAndLog(error)
15
+ process.exitCode = 1
16
+ }
17
+ }
@@ -4,6 +4,9 @@ const Precommit = require('./../../../lib/services/precommit')
4
4
  const catchAndLog = require('./../../../lib/helpers/catchAndLog')
5
5
 
6
6
  function precommit (directory) {
7
+ if (this.opts().clean !== undefined) {
8
+ return require('./precommitClean')(this.opts().clean)
9
+ }
7
10
  // debug args
8
11
  logger.debug(`directory: ${directory}`)
9
12
 
@@ -0,0 +1,21 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { sealed } = require('@dotenvx/primitives')
4
+
5
+ function precommitClean (filepath) {
6
+ try {
7
+ const content = fs.readFileSync(0)
8
+ const filename = path.posix.basename(filepath)
9
+ const exempt = ['.env.example', '.env.vault', '.env.x'].includes(filename)
10
+ if (filename.startsWith('.env.keys') || (!exempt && !sealed(content.toString('utf8')))) {
11
+ throw new Error(`refusing to stage ${JSON.stringify(filepath)}: encrypt this env file or add it to .gitignore`)
12
+ }
13
+ // stdout is Git's blob content: no logging, normalization, or extra newline.
14
+ process.stdout.write(content)
15
+ } catch (error) {
16
+ process.stderr.write(`dotenvx: ${error.message}\n`)
17
+ process.exitCode = 1
18
+ }
19
+ }
20
+
21
+ module.exports = precommitClean
@@ -18,7 +18,7 @@ async function get (key) {
18
18
  const spinnerOptions = typeof this.optsWithGlobals === 'function' ? this.optsWithGlobals() : options
19
19
  const spinner = await createSpinner({ ...spinnerOptions, ...options, text: 'decrypting' })
20
20
 
21
- logger.debug(`options: ${JSON.stringify(options)}`)
21
+ logger.debug(`options: ${JSON.stringify({ ...options, ...(options.lockPassword !== undefined ? { lockPassword: '[REDACTED]' } : {}) })}`)
22
22
  if (key) {
23
23
  logger.debug(`key: ${key}`)
24
24
  }
@@ -41,6 +41,7 @@ async function get (key) {
41
41
  envKeysFile: resolveEnvKeysFile(options.envKeysFile),
42
42
  noArmor,
43
43
  noNative,
44
+ lockPassword: options.lockPassword,
44
45
  no1Password: options['1password'] === false || options.no1Password === true,
45
46
  noBitwarden: options.bitwarden === false || options.noBitwarden === true,
46
47
  onStatus: (text) => {
@@ -0,0 +1,3 @@
1
+ module.exports = function init () {
2
+ return require('./define').apply(this, arguments)
3
+ }
@@ -1,32 +1,10 @@
1
- const crypto = require('crypto')
1
+ const unlockedValue = require('../../../lib/helpers/unlockedValue')
2
2
 
3
3
  const { logger } = require('../../../shared/logger')
4
4
  const LockDown = require('./../../../lib/services/lockDown')
5
5
  const armoredKeyDisplay = require('../../../lib/helpers/armoredKeyDisplay')
6
6
  const prompts = require('../../../lib/helpers/prompts')
7
-
8
- function unlockedValue (lockedPrivateKey, passphrase) {
9
- const parts = lockedPrivateKey.split(':')
10
- const payload = Buffer.from(parts.slice(2).join(':'), 'base64url')
11
- const version = payload.subarray(0, 1)[0]
12
- const salt = payload.subarray(1, 17)
13
- const iv = payload.subarray(17, 29)
14
- const tag = payload.subarray(29, 45)
15
- const ciphertext = payload.subarray(45)
16
- const key = crypto.scryptSync(passphrase, salt, 32)
17
- const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
18
-
19
- if (version !== 1) {
20
- throw new Error('unsupported locked private key version')
21
- }
22
-
23
- decipher.setAuthTag(tag)
24
-
25
- return Buffer.concat([
26
- decipher.update(ciphertext),
27
- decipher.final()
28
- ]).toString('utf8')
29
- }
7
+ const resolveLockPassword = require('../../../lib/helpers/resolveLockPassword')
30
8
 
31
9
  async function down () {
32
10
  const options = this.opts()
@@ -38,14 +16,17 @@ async function down () {
38
16
  let results = plan.alreadyUnlocked
39
17
 
40
18
  if (plan.locked.length > 0) {
41
- const passphrase = await prompts.password({
42
- message: 'passphrase',
43
- prefix: '⊡',
44
- separator: '='
45
- }, {
46
- input: process.stdin,
47
- output: process.stderr
48
- })
19
+ const preset = resolveLockPassword(options)
20
+ const passphrase = preset !== undefined
21
+ ? preset
22
+ : await prompts.password({
23
+ message: 'passphrase',
24
+ prefix: '⊡',
25
+ separator: '='
26
+ }, {
27
+ input: process.stdin,
28
+ output: process.stderr
29
+ })
49
30
 
50
31
  results = lockDown.run(lockedPrivateKey => unlockedValue(lockedPrivateKey, passphrase)).results
51
32
  }
@@ -1,30 +1,10 @@
1
- const crypto = require('crypto')
1
+ const lockedValue = require('../../../lib/helpers/lockedValue')
2
2
 
3
3
  const { logger } = require('../../../shared/logger')
4
4
  const LockUp = require('./../../../lib/services/lockUp')
5
5
  const armoredKeyDisplay = require('../../../lib/helpers/armoredKeyDisplay')
6
6
  const prompts = require('../../../lib/helpers/prompts')
7
-
8
- function lockedValue (privateKey, passphrase, publicKey) {
9
- const salt = crypto.randomBytes(16)
10
- const iv = crypto.randomBytes(12)
11
- const key = crypto.scryptSync(passphrase, salt, 32)
12
- const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
13
- const ciphertext = Buffer.concat([
14
- cipher.update(privateKey, 'utf8'),
15
- cipher.final()
16
- ])
17
- const tag = cipher.getAuthTag()
18
- const payload = Buffer.concat([
19
- Buffer.from([1]),
20
- salt,
21
- iv,
22
- tag,
23
- ciphertext
24
- ]).toString('base64url')
25
-
26
- return `locked:${publicKey}:${payload}`
27
- }
7
+ const resolveLockPassword = require('../../../lib/helpers/resolveLockPassword')
28
8
 
29
9
  async function up () {
30
10
  const options = this.opts()
@@ -44,14 +24,17 @@ async function up () {
44
24
  }))
45
25
 
46
26
  if (plan.matches.length > 0) {
47
- const passphrase = await prompts.password({
48
- message: 'passphrase',
49
- prefix: '⊡',
50
- separator: '='
51
- }, {
52
- input: process.stdin,
53
- output: process.stderr
54
- })
27
+ const preset = resolveLockPassword(options)
28
+ const passphrase = preset !== undefined
29
+ ? preset
30
+ : await prompts.password({
31
+ message: 'passphrase',
32
+ prefix: '⊡',
33
+ separator: '='
34
+ }, {
35
+ input: process.stdin,
36
+ output: process.stderr
37
+ })
55
38
 
56
39
  results = lockUp.run((privateKey, publicKey) => lockedValue(privateKey, passphrase, publicKey)).results
57
40
  }
@@ -25,7 +25,7 @@ function inferCommandArgsFromProcessArgv (argv) {
25
25
  if (separatorIndex !== -1) return args.slice(separatorIndex + 1)
26
26
 
27
27
  for (let i = 0; i < args.length; i++) {
28
- if (args[i] === '-f' || args[i] === '--env-file') {
28
+ if (args[i] === '-f' || args[i] === '--file' || args[i] === '--env-file') {
29
29
  i++
30
30
  continue
31
31
  }
@@ -77,6 +77,7 @@ async function run () {
77
77
 
78
78
  debugOptions = { ...options, env: (options.env || []).map(envSrc => maskEnvSrc(envSrc, showChar)), token }
79
79
  }
80
+ if (options.lockPassword !== undefined) debugOptions = { ...debugOptions, lockPassword: '[REDACTED]' }
80
81
  logger.debug(`options: ${JSON.stringify(debugOptions)}`)
81
82
  logger.debug(`process command [${commandArgs.join(' ')}]`)
82
83
 
@@ -1,4 +1,4 @@
1
- function configureCustodyCommand (command, name, providerPath) {
1
+ function configureCustodyCommand (command, name, custodianId) {
2
2
  command.hook('preAction', async () => {
3
3
  const Session = require('../../db/session')
4
4
  await new Session().notifyUpdate()
@@ -23,7 +23,7 @@ function configureCustodyCommand (command, name, providerPath) {
23
23
  const options = this.opts()
24
24
  const spinner = await createSpinner({ ...this.optsWithGlobals(), text: `${operation === 'up' || operation === 'push' ? 'storing in' : 'reading from'} ${name}` })
25
25
  try {
26
- const result = await transfer(require(providerPath), name, operation, options.envFile, options.envKeysFile)
26
+ const result = await transfer(require('../../lib/custodians').get(custodianId), name, operation, options.envFile, options.envKeysFile)
27
27
  if (spinner) spinner.stop()
28
28
  const display = armoredKeyDisplay(result.publicKeyValue) || result.privateKeyName
29
29
  const messages = { up: `stored in ${name}`, down: `moved to ${options.envKeysFile}`, push: `pushed to ${name}`, pull: `pulled to ${options.envKeysFile}` }
@@ -61,7 +61,9 @@ ext.command('precommit')
61
61
  .description('prevent committing .env files to code')
62
62
  .addHelpText('after', help.precommit)
63
63
  .argument('[directory]', 'directory to prevent committing .env files from', '.')
64
- .option('-i, --install', 'install to .git/hooks/pre-commit')
64
+ .option('-i, --install', 'install a pre-commit hook and required Git clean filter')
65
+ .option('--global', 'with --install, install the clean filter for all repositories')
66
+ .addOption(ext.createOption('--clean <path>', 'validate Git filter input from stdin').hideHelp())
65
67
  .action(function (...args) {
66
68
  return require('./../actions/ext/precommit').apply(this, args)
67
69
  })
@@ -0,0 +1,22 @@
1
+ // Keep the existing envFile option attribute and parsing behavior for both
2
+ // spellings, including mixed repeated flags. Only --file is advertised.
3
+ function configureFileOptions (command) {
4
+ const legacy = command.options.find(option => option.long === '--env-file')
5
+ if (legacy && !command.options.some(option => option.long === '--file')) {
6
+ const index = command.options.indexOf(legacy)
7
+ const visible = command.createOption(legacy.flags.replace('--env-file', '--file'), legacy.description)
8
+ visible.attributeName = () => legacy.attributeName()
9
+ if (legacy.parseArg) visible.argParser(legacy.parseArg)
10
+ if (legacy.defaultValue !== undefined) visible.default(legacy.defaultValue, legacy.defaultValueDescription)
11
+ legacy.short = undefined
12
+ legacy.flags = legacy.flags.replace(/^-f,?\s*/, '')
13
+ legacy.hideHelp()
14
+ command.addOption(visible)
15
+ command.options.pop()
16
+ command.options.splice(index, 0, visible)
17
+ }
18
+ for (const child of command.commands) configureFileOptions(child)
19
+ return command
20
+ }
21
+
22
+ module.exports = configureFileOptions
@@ -78,6 +78,7 @@ program.command('run')
78
78
  .option('--ignore <errorCodes...>', 'error code(s) to ignore (example: --ignore=MISSING_ENV_FILE)')
79
79
  .option('--token <token>', 'set Armor ⛨ token')
80
80
  .option('--mask [characters]', 'inject masked values, optionally setting visible characters')
81
+ .option('--lock-password <password>', 'password to unlock private keys (defaults to DOTENVX_LOCK_PASSWORD)')
81
82
  .option('--no-armor', 'disable Dotenvx Armor features')
82
83
  .option('--no-native', 'disable OS secret store features')
83
84
  .option('--no-1password', 'disable 1Password secret reference resolution')
@@ -106,6 +107,7 @@ program.command('get')
106
107
  .option('-pp, --pretty-print', 'pretty print output')
107
108
  .option('--pp', 'pretty print output (alias)')
108
109
  .option('--format <type>', 'format of the output (json, shell, colon, eval, eval-export)', 'json')
110
+ .option('--lock-password <password>', 'password to unlock private keys (defaults to DOTENVX_LOCK_PASSWORD)')
109
111
  .option('--no-armor', 'disable Dotenvx Armor features')
110
112
  .option('--no-native', 'disable OS secret store features')
111
113
  .option('--no-1password', 'disable 1Password secret reference resolution')
@@ -211,7 +213,7 @@ program.command('keypair')
211
213
  })
212
214
 
213
215
  // dotenvx ls
214
- program.command('ls')
216
+ program.command('ls', { hidden: true })
215
217
  .description('print all .env files in a tree structure')
216
218
  .argument('[directory]', 'directory to list .env files from', '.')
217
219
  .option('-f, --env-file <filenames...>', 'path(s) to your env file(s)', '.env*')
@@ -221,8 +223,24 @@ program.command('ls')
221
223
  return require('./actions/ls').apply(this, args)
222
224
  })
223
225
 
226
+ // dotenvx init
227
+ program.command('init', { hidden: true })
228
+ .description('create an Envfile from .env.example and .env')
229
+ .option('-f, --env-file <path>', 'file to read variable names from')
230
+ .action(function () {
231
+ return require('./actions/init').apply(this, arguments)
232
+ })
233
+
234
+ // dotenvx define
235
+ program.command('define', { hidden: true })
236
+ .description('define your environment in an Envfile')
237
+ .option('-f, --env-file <path>', 'file to read variable names from')
238
+ .action(function () {
239
+ return require('./actions/define').apply(this, arguments)
240
+ })
241
+
224
242
  // dotenvx validate
225
- program.command('validate')
243
+ program.command('validate', { hidden: true })
226
244
  .description('validate .env file(s) against Envfile')
227
245
  .option('--strict', 'process.exit(1) on any errors, including missing env files', false)
228
246
  .option('-e, --env <strings...>', 'environment variable(s) set as string (example: "HELLO=World")', collectEnvs('env'), [])
@@ -242,7 +260,7 @@ program.command('validate')
242
260
  })
243
261
 
244
262
  // dotenvx gitignore
245
- program.command('gitignore')
263
+ program.command('gitignore', { hidden: true })
246
264
  .description('append to .gitignore')
247
265
  .addHelpText('after', help.gitignore)
248
266
  .option('--pattern <patterns...>', 'pattern(s) to gitignore', ['.env*'])
@@ -251,7 +269,7 @@ program.command('gitignore')
251
269
  })
252
270
 
253
271
  // dotenvx genexample
254
- program.command('genexample')
272
+ program.command('genexample', { hidden: true })
255
273
  .description('generate .env.example')
256
274
  .argument('[directory]', 'directory to generate from', '.')
257
275
  .option('-f, --env-file <paths...>', 'path(s) to your env file(s)', '.env')
@@ -260,17 +278,19 @@ program.command('genexample')
260
278
  })
261
279
 
262
280
  // dotenvx precommit
263
- program.command('precommit')
281
+ program.command('precommit', { hidden: true })
264
282
  .description('prevent committing .env files to code')
265
283
  .addHelpText('after', help.precommit)
266
284
  .argument('[directory]', 'directory to prevent committing .env files from', '.')
267
- .option('-i, --install', 'install to .git/hooks/pre-commit')
285
+ .option('-i, --install', 'install a pre-commit hook and required Git clean filter')
286
+ .option('--global', 'with --install, install the clean filter for all repositories')
287
+ .addOption(program.createOption('--clean <path>', 'validate Git filter input from stdin').hideHelp())
268
288
  .action(function (...args) {
269
289
  return require('./actions/ext/precommit').apply(this, args)
270
290
  })
271
291
 
272
292
  // dotenvx prebuild
273
- program.command('prebuild')
293
+ program.command('prebuild', { hidden: true })
274
294
  .description('prevent including .env files in docker')
275
295
  .addHelpText('after', help.prebuild)
276
296
  .argument('[directory]', 'directory to prevent including .env files from', '.')
@@ -286,6 +306,13 @@ program.command('doctor', { hidden: true })
286
306
  return require('./actions/doctor').apply(this, args)
287
307
  })
288
308
 
309
+ // dotenvx hidden (a menu of top-level commands)
310
+ program.command('hidden')
311
+ .allowExcessArguments(false)
312
+ .description('hidden features')
313
+ .addHelpText('after', '\nHidden Commands:\n init create an Envfile from .env.example and .env\n define define your environment in an Envfile\n validate validate .env file(s) against Envfile\n genexample [directory] generate .env.example\n gitignore append to .gitignore\n ls [directory] print all .env files in a tree structure\n prebuild [directory] prevent including .env files in docker\n precommit [directory] prevent committing .env files to code\n lock ⊡ lock private keys with a local passphrase\n native ⌥ move private keys in/out of your OS secret store\n 1password □ move private keys in/out of 1Password\n bitwarden □ move private keys in/out of Bitwarden\n armor ⛨ move private keys in/out of Dotenvx Armor [www.dotenvx.com/armor]\n curl ⛨ call authenticated api Dotenvx Armor [www.dotenvx.com/armor]\n\nRun directly: dotenvx <command>')
314
+ .action(function () { this.outputHelp() })
315
+
289
316
  // dotenvx update
290
317
  program.command('update')
291
318
  .description('update dotenvx')
@@ -344,20 +371,8 @@ program.command('help [command]')
344
371
  }
345
372
  })
346
373
 
347
- // security sections (hidden commands advertised here)
348
- program.addHelpText('after', ' ')
349
- program.addHelpText('after', 'Local Custody:')
350
- program.addHelpText('after', ' lock ⊡ lock private keys with a local passphrase')
351
- program.addHelpText('after', ' native ⌥ move private keys in/out of your OS secret store')
352
- program.addHelpText('after', ' 1password □ move private keys in/out of 1Password')
353
- program.addHelpText('after', ' bitwarden □ move private keys in/out of Bitwarden')
354
- program.addHelpText('after', ' ')
355
- program.addHelpText('after', 'Managed Custody:')
356
- program.addHelpText('after', ' armor ⛨ move private keys in/out of Dotenvx Armor [www.dotenvx.com/armor]')
357
- program.addHelpText('after', ' curl ⛨ call authenticated api Dotenvx Armor [www.dotenvx.com/armor]')
358
-
359
- require('./commands/custody')(program.command('1password', { hidden: true }), '1Password', '../../lib/helpers/onePasswordCustody')
360
- require('./commands/custody')(program.command('bitwarden', { hidden: true }), 'Bitwarden', '../../lib/helpers/bitwardenCustody')
374
+ require('./commands/custody')(program.command('1password', { hidden: true }), '1Password', 'onepassword')
375
+ require('./commands/custody')(program.command('bitwarden', { hidden: true }), 'Bitwarden', 'bitwarden')
361
376
 
362
377
  // dotenvx native
363
378
  require('./commands/native')(program.command('native', { hidden: true }))
@@ -390,4 +405,6 @@ program.helpInformation = function () {
390
405
  }
391
406
  /* c8 ignore stop */
392
407
 
408
+ require('./commands/fileOptions')(program)
409
+
393
410
  program.parse(process.argv)
@@ -0,0 +1,73 @@
1
+ const protection = require('./lock')
2
+
3
+ // Explicit registration keeps bundling predictable and avoids executing
4
+ // arbitrary modules discovered in a project's working directory.
5
+ const builtins = [
6
+ require('./local/native'),
7
+ require('./local/onepassword'),
8
+ require('./local/bitwarden'),
9
+ require('./local/file'),
10
+ require('./managed/armor')
11
+ ]
12
+
13
+ function createRegistry (custodians) {
14
+ const entries = new Map()
15
+ for (const custodian of custodians) {
16
+ if (!custodian || typeof custodian.id !== 'string' || !custodian.id || entries.has(custodian.id)) {
17
+ throw new Error('custodians must have unique, nonempty ids')
18
+ }
19
+ for (const method of ['enabled', 'available', 'store']) {
20
+ if (typeof custodian[method] !== 'function') throw new Error(`custodian ${custodian.id} requires ${method}()`)
21
+ }
22
+ entries.set(custodian.id, custodian)
23
+ }
24
+
25
+ function get (id) {
26
+ const custodian = entries.get(id)
27
+ if (!custodian) throw new Error(`unknown custodian: ${id}`)
28
+ return custodian
29
+ }
30
+
31
+ return {
32
+ get,
33
+ async choices (options = {}, custody = 'local') {
34
+ const choices = []
35
+ for (const custodian of entries.values()) {
36
+ if ((custodian.custody || 'local') !== custody) continue
37
+ choices.push({ name: custodian.name, value: custodian.id, disabled: !custodian.enabled(options) || !await custodian.available() })
38
+ }
39
+ return choices
40
+ },
41
+ providers (options = {}, sync = false) {
42
+ const providers = []
43
+ for (const custodian of entries.values()) {
44
+ if (custodian.custody === 'managed') continue
45
+ const method = sync ? 'getSync' : 'get'
46
+ if (!custodian.enabled(options) || !custodian.get || (custodian.configured && !custodian.configured())) continue
47
+ if (typeof custodian[method] !== 'function') throw new Error(`custodian ${custodian.id} does not support synchronous reads`)
48
+ if (sync) {
49
+ providers.push(publicKey => protection.unlockSync(publicKey, custodian[method](publicKey), options))
50
+ } else {
51
+ providers.push(async publicKey => protection.unlock(publicKey, await custodian[method](publicKey), options))
52
+ }
53
+ }
54
+ return providers
55
+ },
56
+ async store (selection, publicKey, privateKey, context = {}) {
57
+ const { id, lock = false } = typeof selection === 'string' ? { id: selection } : selection
58
+ const custodian = get(id)
59
+ if (lock) {
60
+ if (custodian.custody === 'managed') throw new Error('password locking is only supported for local custody')
61
+ privateKey = await protection.lock(publicKey, privateKey, context)
62
+ }
63
+ const result = await custodian.store(publicKey, privateKey, context) || {}
64
+ // Only the native custodian's unavailable-write path requests fallback.
65
+ // Authentication and verification errors propagate without writing a file.
66
+ if (result.fallback) return get(result.fallback).store(publicKey, privateKey, context)
67
+ return result
68
+ }
69
+ }
70
+ }
71
+
72
+ module.exports = createRegistry(builtins)
73
+ module.exports.createRegistry = createRegistry
@@ -1,11 +1,11 @@
1
1
  const { execFile, execFileSync } = require('child_process')
2
- const { derive } = require('@dotenvx/primitives')
3
- const Session = require('../../db/session')
4
- const prompts = require('./prompts')
5
- const createSpinner = require('./createSpinner')
2
+ const matchesStoredKey = require('../../helpers/matchesStoredKey')
3
+ const Session = require('../../../db/session')
4
+ const prompts = require('../../helpers/prompts')
5
+ const createSpinner = require('../../helpers/createSpinner')
6
6
  let unlockedSession
7
7
 
8
- const armoredKeyDisplay = require('./armoredKeyDisplay')
8
+ const armoredKeyDisplay = require('../../helpers/armoredKeyDisplay')
9
9
 
10
10
  const PREFIX = 'DOTENVX_BITWARDEN_'
11
11
  const ID = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i
@@ -110,7 +110,7 @@ function checkIdentity (status, loc) {
110
110
  }
111
111
 
112
112
  function verified (publicKey, privateKey) {
113
- try { if (derive(privateKey) === publicKey) return { [publicKey]: privateKey } } catch {}
113
+ try { if (matchesStoredKey(publicKey, privateKey)) return { [publicKey]: privateKey } } catch {}
114
114
  throw failure('Bitwarden private key does not match the .env public key')
115
115
  }
116
116
 
@@ -155,6 +155,7 @@ async function set (publicKey, privateKey) {
155
155
  if (!item || !ID.test(item.id || '') || item.organizationId) throw failure('Bitwarden did not return a personal vault item')
156
156
  const saved = (await run(['get', 'password', item.id])).trim()
157
157
  verified(publicKey, saved)
158
+ if (saved !== privateKey) throw failure('could not verify private key in Bitwarden')
158
159
  const loc = { item: item.id, userId: status.userId, serverUrl: status.serverUrl || '' }
159
160
  new Session().createStore().set(`${PREFIX}${publicKey}`, Buffer.from(JSON.stringify(loc)).toString('base64'))
160
161
  }
@@ -167,4 +168,8 @@ async function remove (publicKey) {
167
168
  new Session().openStore().delete(`${PREFIX}${publicKey}`)
168
169
  }
169
170
 
170
- module.exports = { available, configured, get, getSync, set, delete: remove }
171
+ function enabled (options = {}) {
172
+ return options.noBitwarden !== true && process.env.DOTENVX_NO_BITWARDEN !== 'true'
173
+ }
174
+
175
+ module.exports = { id: 'bitwarden', name: 'Bitwarden', enabled, store: set, available, configured, get, getSync, set, delete: remove }