dotenv-gad 1.4.2 → 1.5.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.
Files changed (38) hide show
  1. package/README.md +47 -10
  2. package/dist/cli/commands/check.js +10 -2
  3. package/dist/cli/commands/decrypt.js +126 -0
  4. package/dist/cli/commands/docs.js +2 -2
  5. package/dist/cli/commands/encrypt.js +94 -0
  6. package/dist/cli/commands/fix.js +16 -12
  7. package/dist/cli/commands/init.js +17 -12
  8. package/dist/cli/commands/keygen.js +97 -0
  9. package/dist/cli/commands/rotate.js +160 -0
  10. package/dist/cli/commands/status.js +116 -0
  11. package/dist/cli/commands/sync.js +15 -17
  12. package/dist/cli/commands/utils.js +34 -28
  13. package/dist/cli/commands/verify.js +101 -0
  14. package/dist/cli/index.js +12 -0
  15. package/dist/crypto.js +162 -0
  16. package/dist/errors.js +92 -4
  17. package/dist/index.cjs +375 -24
  18. package/dist/index.js +5 -4
  19. package/dist/types/cli/commands/check.d.ts +1 -1
  20. package/dist/types/cli/commands/decrypt.d.ts +2 -0
  21. package/dist/types/cli/commands/docs.d.ts +1 -1
  22. package/dist/types/cli/commands/encrypt.d.ts +2 -0
  23. package/dist/types/cli/commands/fix.d.ts +1 -1
  24. package/dist/types/cli/commands/init.d.ts +1 -1
  25. package/dist/types/cli/commands/keygen.d.ts +2 -0
  26. package/dist/types/cli/commands/rotate.d.ts +2 -0
  27. package/dist/types/cli/commands/status.d.ts +2 -0
  28. package/dist/types/cli/commands/sync.d.ts +1 -1
  29. package/dist/types/cli/commands/verify.d.ts +2 -0
  30. package/dist/types/crypto.d.ts +58 -0
  31. package/dist/types/errors.d.ts +35 -12
  32. package/dist/types/index.d.ts +7 -3
  33. package/dist/types/schema.d.ts +7 -0
  34. package/dist/types/utils.d.ts +19 -10
  35. package/dist/types/validator.d.ts +40 -0
  36. package/dist/utils.js +30 -12
  37. package/dist/validator.js +161 -18
  38. package/package.json +1 -2
package/README.md CHANGED
@@ -12,10 +12,11 @@
12
12
 
13
13
  - Type-safe environment variables with full IntelliSense
14
14
  - Schema validation (string, number, boolean, url, email, ip, port, json, array, object)
15
+ - **At-rest encryption** — transparent decrypt at runtime
15
16
  - Schema composition for modular configs
16
17
  - Automatic documentation and `.env.example` generation
17
18
  - First-class TypeScript support
18
- - CLI tooling (check, sync, types, init, fix, docs)
19
+ - CLI tooling (check, sync, types, init, fix, docs, keygen, encrypt, decrypt, rotate, verify, status)
19
20
  - Sensitive value management and redaction
20
21
  - Vite plugin with client-safe filtering and HMR
21
22
 
@@ -70,18 +71,25 @@ Full documentation is available at [kasimlyee.github.io/dotenv-gad](https://kasi
70
71
 
71
72
  ## CLI Commands
72
73
 
73
- | Command | Description |
74
- | ------- | ---------------------------------- |
75
- | `check` | Validate .env against schema |
76
- | `sync` | Generate/update .env.example |
77
- | `types` | Generate env.d.ts TypeScript types |
78
- | `init` | Create starter schema |
79
- | `fix` | Fixes environment issues |
80
- | `docs` | Generates .env documentation |
74
+ | Command | Description |
75
+ | --------- | ------------------------------------------------ |
76
+ | `check` | Validate .env against schema |
77
+ | `sync` | Generate/update .env.example |
78
+ | `types` | Generate env.d.ts TypeScript types |
79
+ | `init` | Create starter schema |
80
+ | `fix` | Fix environment issues interactively |
81
+ | `docs` | Generate .env documentation |
82
+ | `keygen` | Generate an X25519 key pair for encryption |
83
+ | `encrypt` | Encrypt plaintext values for `encrypted:true` fields |
84
+ | `decrypt` | Print or write back decrypted values |
85
+ | `rotate` | Rotate keys: decrypt → new pair → re-encrypt |
86
+ | `status` | Show encryption status of each schema field |
87
+ | `verify` | Dry-run: confirm all encrypted values decrypt |
81
88
 
82
89
  ```bash
83
90
  npx dotenv-gad check
84
- npx dotenv-gad types
91
+ npx dotenv-gad keygen
92
+ npx dotenv-gad encrypt
85
93
  ```
86
94
 
87
95
  ## Vite Plugin
@@ -178,6 +186,35 @@ const schema = composeSchema(baseSchema, dbSchema);
178
186
  }
179
187
  ```
180
188
 
189
+ ### At-rest Encryption
190
+
191
+ Store secrets as encrypted ciphertext in `.env` using asymmetric X25519 + ChaCha20-Poly1305. Only `.env.keys` (gitignored) can decrypt them.
192
+
193
+ **1. Mark fields as encrypted:**
194
+
195
+ ```typescript
196
+ export default defineSchema({
197
+ DATABASE_URL: { type: 'string', required: true, sensitive: true, encrypted: true },
198
+ API_SECRET: { type: 'string', required: true, sensitive: true, encrypted: true },
199
+ });
200
+ ```
201
+
202
+ **2. Generate keys and encrypt:**
203
+
204
+ ```bash
205
+ npx dotenv-gad keygen # writes ENVGAD_PUBLIC_KEY to .env, creates .env.keys
206
+ npx dotenv-gad encrypt # replaces plaintext secrets with encrypted:v1:… tokens
207
+ ```
208
+
209
+ **3. `loadEnv` decrypts transparently at runtime:**
210
+
211
+ ```typescript
212
+ const env = loadEnv(schema); // reads .env.keys, decrypts, validates — all in one step
213
+ console.log(env.DATABASE_URL); // "postgres://user:pass@host/db"
214
+ ```
215
+
216
+ Your `.env` (with encrypted values and the public key) is now safe to commit. See the [Encryption guide](https://kasimlyee.github.io/dotenv-gad/guide/encryption) for key rotation, CI/CD setup, and security details.
217
+
181
218
  ### Grouping / Namespaced Envs
182
219
 
183
220
  Group related variables into a single validated object:
@@ -2,13 +2,15 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import ora from "ora";
4
4
  import { validateEnv } from "../../index.js";
5
- import { EnvAggregateError } from "../../errors.js";
5
+ import { EnvAggregateError, EncryptionKeyMissingError } from "../../errors.js";
6
6
  import { loadSchema } from "./utils.js";
7
- export default function (program) {
7
+ export default function (_program) {
8
8
  return new Command("check")
9
9
  .description("Validate .env against schema")
10
10
  .option("--strict", "Fail on extra env vars not in schema")
11
11
  .option("--fix", "Attempt to fix errors interactively")
12
+ .option("--keys <file>", "Path to .env.keys file for decrypting encrypted fields", ".env.keys")
13
+ .option("--allow-plaintext", "Warn instead of error when encrypted fields have plaintext values")
12
14
  .action(async (option, command) => {
13
15
  const rootOpts = command.parent.opts();
14
16
  const spinner = ora("Validating environment...").start();
@@ -17,6 +19,8 @@ export default function (program) {
17
19
  const env = validateEnv(schema, {
18
20
  strict: option.strict,
19
21
  path: rootOpts.env,
22
+ keysPath: option.keys,
23
+ allowPlaintext: option.allowPlaintext,
20
24
  });
21
25
  spinner.succeed(chalk.green("Environment validation passed!"));
22
26
  console.log(chalk.dim(`Found ${Object.keys(env).length} valid variables`));
@@ -33,6 +37,10 @@ export default function (program) {
33
37
  });
34
38
  process.exit(1);
35
39
  }
40
+ else if (error instanceof EncryptionKeyMissingError) {
41
+ console.error(chalk.red(`\n✗ ${error.message}`));
42
+ process.exit(1);
43
+ }
36
44
  else {
37
45
  console.error(chalk.red("Unexpected error:"), error);
38
46
  process.exit(2);
@@ -0,0 +1,126 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { readFileSync, writeFileSync } from "node:fs";
6
+ import dotenv from "dotenv";
7
+ import { decryptEnvValue, isEncryptedValue, loadPrivateKey } from "../../crypto.js";
8
+ import { EncryptionKeyMissingError } from "../../errors.js";
9
+ import { loadSchema } from "./utils.js";
10
+ async function confirm(question) {
11
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
12
+ try {
13
+ const answer = await rl.question(question);
14
+ return answer.trim().toLowerCase() === "y";
15
+ }
16
+ finally {
17
+ rl.close();
18
+ }
19
+ }
20
+ export default function (_program) {
21
+ return new Command("decrypt")
22
+ .description("Decrypt encrypted values and print them to stdout (or write back with --write)")
23
+ .option("--keys <file>", "Path to the .env.keys private key file", ".env.keys")
24
+ .option("--write", "Write decrypted values back to the .env file (requires confirmation)")
25
+ .action(async (opts, command) => {
26
+ const rootOpts = command.parent.opts();
27
+ const envPath = rootOpts.env ?? ".env";
28
+ const schemaPath = rootOpts.schema ?? "env.schema.ts";
29
+ const keysPath = opts.keys;
30
+ const spinner = ora("Loading schema…").start();
31
+ try {
32
+ const schema = await loadSchema(schemaPath);
33
+ const encryptedFields = Object.keys(schema).filter((k) => schema[k].encrypted === true);
34
+ if (encryptedFields.length === 0) {
35
+ spinner.info(chalk.dim("No fields with encrypted: true found in schema."));
36
+ return;
37
+ }
38
+ spinner.text = `Reading ${envPath}…`;
39
+ let envContent;
40
+ try {
41
+ envContent = readFileSync(envPath, "utf8");
42
+ }
43
+ catch {
44
+ spinner.fail(chalk.red(`Cannot read ${envPath}`));
45
+ process.exit(1);
46
+ }
47
+ const parsed = dotenv.parse(envContent);
48
+ spinner.text = "Loading private key…";
49
+ const privateKeyHex = loadPrivateKey({ keysPath });
50
+ if (!privateKeyHex) {
51
+ spinner.fail(chalk.red("Private key not found.\n" +
52
+ ` Checked: ${keysPath} and ENVGAD_PRIVATE_KEY env var\n` +
53
+ " Obtain .env.keys from your team or set ENVGAD_PRIVATE_KEY"));
54
+ process.exit(1);
55
+ }
56
+ spinner.text = `Decrypting ${encryptedFields.length} field(s)…`;
57
+ const decrypted = {};
58
+ const errors = [];
59
+ for (const key of encryptedFields) {
60
+ const value = parsed[key];
61
+ if (value == null || value === "")
62
+ continue;
63
+ if (!isEncryptedValue(value)) {
64
+ console.warn(chalk.yellow(` ⚠ ${key}: value is not encrypted (skipping)`));
65
+ continue;
66
+ }
67
+ try {
68
+ decrypted[key] = decryptEnvValue(value, privateKeyHex, key);
69
+ }
70
+ catch (err) {
71
+ errors.push({
72
+ key,
73
+ message: err instanceof Error ? err.message : "Decryption failed",
74
+ });
75
+ }
76
+ }
77
+ spinner.stop();
78
+ if (errors.length > 0) {
79
+ for (const { key, message } of errors) {
80
+ console.error(` ${chalk.red("✗")} ${chalk.bold(key)}: ${message}`);
81
+ }
82
+ }
83
+ const decryptedKeys = Object.keys(decrypted);
84
+ if (decryptedKeys.length === 0) {
85
+ console.log(chalk.dim("No encrypted values found to decrypt."));
86
+ return;
87
+ }
88
+ if (!opts.write) {
89
+ // Default: safe stdout output
90
+ console.log(chalk.bold("\n# Decrypted values (stdout only — not written to file):"));
91
+ for (const [key, value] of Object.entries(decrypted)) {
92
+ console.log(`${key}=${value}`);
93
+ }
94
+ console.log(chalk.dim(`\n${decryptedKeys.length} value(s) printed. ` +
95
+ "Use --write to update the .env file."));
96
+ return;
97
+ }
98
+ // --write mode: confirm before overwriting
99
+ console.log(chalk.yellow(`\n⚠ About to write ${decryptedKeys.length} decrypted value(s) to ${envPath}.`) +
100
+ chalk.dim("\n This replaces encrypted values with plaintext.\n" +
101
+ " DO NOT commit these changes to git!\n"));
102
+ const ok = await confirm(" Continue? (y/N): ");
103
+ if (!ok) {
104
+ console.log(chalk.dim("\nAborted."));
105
+ return;
106
+ }
107
+ const replaceRegexes = Object.fromEntries(Object.keys(decrypted).map((k) => [k, new RegExp(`^(${k}\\s*=).*$`, "m")]));
108
+ let updatedContent = envContent;
109
+ for (const [key, value] of Object.entries(decrypted)) {
110
+ updatedContent = updatedContent.replace(replaceRegexes[key], `$1${value}`);
111
+ }
112
+ writeFileSync(envPath, updatedContent);
113
+ console.log(`\n${chalk.green("✓")} ${decryptedKeys.length} value(s) written to ${chalk.bold(envPath)}\n` +
114
+ chalk.yellow("⚠ Remember: DO NOT commit these decrypted values!"));
115
+ }
116
+ catch (error) {
117
+ spinner.stop();
118
+ if (error instanceof EncryptionKeyMissingError) {
119
+ console.error(chalk.red(`\n✗ ${error.message}`));
120
+ process.exit(1);
121
+ }
122
+ console.error(chalk.red("\nUnexpected error:"), error);
123
+ process.exit(2);
124
+ }
125
+ });
126
+ }
@@ -1,10 +1,10 @@
1
1
  import { Command } from "commander";
2
2
  import { loadSchema } from "./utils.js";
3
3
  import { writeFileSync } from "fs";
4
- export default function (program) {
4
+ export default function (_program) {
5
5
  return new Command("docs")
6
6
  .description("Generate Markdown documentation")
7
- .action(async (program, command) => {
7
+ .action(async (_program, command) => {
8
8
  const schema = await loadSchema(command.parent.opts().schema);
9
9
  let md = `# Environment Variables\n\n`;
10
10
  Object.entries(schema).forEach(([key, rule]) => {
@@ -0,0 +1,94 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
5
+ import dotenv from "dotenv";
6
+ import { encryptEnvValue, isEncryptedValue } from "../../crypto.js";
7
+ import { EncryptionKeyMissingError } from "../../errors.js";
8
+ import { loadSchema } from "./utils.js";
9
+ export default function (_program) {
10
+ return new Command("encrypt")
11
+ .description("Encrypt plaintext values for fields marked encrypted: true in the schema")
12
+ .action(async (_opts, command) => {
13
+ const rootOpts = command.parent.opts();
14
+ const envPath = rootOpts.env ?? ".env";
15
+ const schemaPath = rootOpts.schema ?? "env.schema.ts";
16
+ const spinner = ora("Loading schema…").start();
17
+ try {
18
+ const schema = await loadSchema(schemaPath);
19
+ const encryptedFields = Object.keys(schema).filter((k) => schema[k].encrypted === true);
20
+ if (encryptedFields.length === 0) {
21
+ spinner.info(chalk.dim("No fields with encrypted: true found in schema. Nothing to do."));
22
+ return;
23
+ }
24
+ spinner.text = `Reading ${envPath}…`;
25
+ let envContent;
26
+ try {
27
+ envContent = readFileSync(envPath, "utf8");
28
+ }
29
+ catch {
30
+ spinner.fail(chalk.red(`Cannot read ${envPath}`));
31
+ process.exit(1);
32
+ }
33
+ const parsed = dotenv.parse(envContent);
34
+ const publicKeyHex = parsed.ENVGAD_PUBLIC_KEY;
35
+ if (!publicKeyHex) {
36
+ spinner.fail(chalk.red(`ENVGAD_PUBLIC_KEY not found in ${envPath}.\n` +
37
+ " Run: " +
38
+ chalk.cyan("npx dotenv-gad keygen")));
39
+ process.exit(1);
40
+ }
41
+ spinner.text = `Encrypting ${encryptedFields.length} field(s)…`;
42
+ const keyRegex = (k) => new RegExp(`^(${k}\\s*=).*$`, "m");
43
+ const replaceRegexes = Object.fromEntries(encryptedFields.map((k) => [k, keyRegex(k)]));
44
+ let updatedContent = envContent;
45
+ let encryptedCount = 0;
46
+ const results = [];
47
+ for (const key of encryptedFields) {
48
+ const value = parsed[key];
49
+ if (value == null || value === "") {
50
+ results.push({ key, status: "missing" });
51
+ continue;
52
+ }
53
+ if (isEncryptedValue(value)) {
54
+ results.push({ key, status: "skipped" });
55
+ continue;
56
+ }
57
+ const encrypted = encryptEnvValue(value, publicKeyHex, key);
58
+ updatedContent = updatedContent.replace(replaceRegexes[key], `$1${encrypted}`);
59
+ results.push({ key, status: "encrypted" });
60
+ encryptedCount++;
61
+ }
62
+ spinner.stop();
63
+ for (const { key, status } of results) {
64
+ if (status === "encrypted") {
65
+ console.log(` ${chalk.green("✓")} ${chalk.bold(key)}: encrypted`);
66
+ }
67
+ else if (status === "skipped") {
68
+ console.log(` ${chalk.dim("⊘")} ${chalk.dim(key + ": already encrypted (skipped)")}`);
69
+ }
70
+ else {
71
+ console.log(` ${chalk.dim("⊘")} ${chalk.dim(key + ": not present in .env (skipped)")}`);
72
+ }
73
+ }
74
+ if (encryptedCount > 0) {
75
+ copyFileSync(envPath, `${envPath}.bak`);
76
+ writeFileSync(envPath, updatedContent);
77
+ console.log(`\n${chalk.green("✓")} ${encryptedCount} field(s) encrypted in ${chalk.bold(envPath)}\n` +
78
+ chalk.dim(` Backup saved to ${envPath}.bak`));
79
+ }
80
+ else {
81
+ console.log(chalk.dim("\nNo fields needed encryption."));
82
+ }
83
+ }
84
+ catch (error) {
85
+ spinner.stop();
86
+ if (error instanceof EncryptionKeyMissingError) {
87
+ console.error(chalk.red(`\n✗ ${error.message}`));
88
+ process.exit(1);
89
+ }
90
+ console.error(chalk.red("\nUnexpected error:"), error);
91
+ process.exit(2);
92
+ }
93
+ });
94
+ }
@@ -1,13 +1,23 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
- import inquirer from "inquirer";
3
+ import { createInterface } from "node:readline/promises";
4
4
  import { loadSchema, applyFix } from "./utils.js";
5
5
  import { validateEnv } from "../../index.js";
6
6
  import { EnvAggregateError } from "../../errors.js";
7
- export default function (program) {
7
+ async function confirm(question) {
8
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9
+ try {
10
+ const answer = await rl.question(question);
11
+ return answer.trim().toLowerCase() === "y";
12
+ }
13
+ finally {
14
+ rl.close();
15
+ }
16
+ }
17
+ export default function (_program) {
8
18
  return new Command("fix")
9
19
  .description("Interactively fix environment issues")
10
- .action(async (options, command) => {
20
+ .action(async (_options, command) => {
11
21
  const rootOpts = command.parent.opts();
12
22
  const schema = await loadSchema(rootOpts.schema);
13
23
  const envPath = rootOpts.env || ".env";
@@ -26,19 +36,13 @@ export default function (program) {
26
36
  console.log(chalk.dim(` ${e.rule.docs}`));
27
37
  }
28
38
  });
29
- const { confirmed } = await inquirer.prompt({
30
- type: "confirm",
31
- name: "confirmed",
32
- message: `\nWould you like to fix these issues interactively?`,
33
- default: true,
34
- });
35
- if (confirmed) {
36
- // Convert errors array to issues object format expected by applyFix
39
+ const ok = await confirm(chalk.bold("\nWould you like to fix these issues interactively? (y/N): "));
40
+ if (ok) {
37
41
  const issues = {};
38
42
  error.errors.forEach((e) => {
39
43
  issues[e.key] = { value: e.value, key: e.key };
40
44
  });
41
- await applyFix(issues, schema, rootOpts.env || ".env");
45
+ await applyFix(issues, schema, envPath);
42
46
  }
43
47
  else {
44
48
  console.log(chalk.dim("\nFix cancelled."));
@@ -2,11 +2,18 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import ora from "ora";
4
4
  import { writeFileSync, existsSync } from "fs";
5
- import inquirer from "inquirer";
6
- import { dirname } from "path";
7
- import { fileURLToPath } from "url";
8
- const __dirname = dirname(fileURLToPath(import.meta.url));
9
- export default function (program) {
5
+ import { createInterface } from "node:readline/promises";
6
+ async function confirm(question) {
7
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
8
+ try {
9
+ const answer = await rl.question(question);
10
+ return answer.trim().toLowerCase() === "y";
11
+ }
12
+ finally {
13
+ rl.close();
14
+ }
15
+ }
16
+ export default function (_program) {
10
17
  return new Command("init")
11
18
  .description("Initialize new schema file")
12
19
  .option("--force", "Overwrite existing files")
@@ -15,14 +22,12 @@ export default function (program) {
15
22
  const schemaPath = rootOpts.schema;
16
23
  if (existsSync(schemaPath)) {
17
24
  if (!options.force) {
18
- const { overwrite } = await inquirer.prompt({
19
- type: "confirm",
20
- name: "overwrite",
21
- message: "Schema file already exists. Overwrite?",
22
- default: false,
23
- });
24
- if (!overwrite)
25
+ const ok = await confirm(chalk.yellow(`Schema file "${schemaPath}" already exists. Overwrite? (y/N): `));
26
+ if (!ok) {
27
+ console.log(chalk.dim("\nAborted."));
25
28
  process.exit(0);
29
+ }
30
+ console.log();
26
31
  }
27
32
  }
28
33
  const spinner = ora("Creating new schema...").start();
@@ -0,0 +1,97 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
6
+ import { generateKeyPair } from "../../crypto.js";
7
+ async function confirm(question) {
8
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9
+ try {
10
+ const answer = await rl.question(question);
11
+ return answer.trim().toLowerCase() === "y";
12
+ }
13
+ finally {
14
+ rl.close();
15
+ }
16
+ }
17
+ export default function (program) {
18
+ return new Command("keygen")
19
+ .description("Generate an X25519 key pair for schema-based encryption")
20
+ .option("--keys <file>", "Path to write the private key file", ".env.keys")
21
+ .option("-f, --force", "Overwrite existing keys without confirmation")
22
+ .action(async (opts, command) => {
23
+ const rootOpts = command.parent.opts();
24
+ const envPath = rootOpts.env ?? ".env";
25
+ const keysPath = opts.keys;
26
+ // Warn and confirm if keys already exist (unless --force)
27
+ const envContent = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
28
+ const hasExistingPublicKey = /^ENVGAD_PUBLIC_KEY=/m.test(envContent);
29
+ const hasExistingKeysFile = existsSync(keysPath);
30
+ if ((hasExistingPublicKey || hasExistingKeysFile) && !opts.force) {
31
+ console.log(chalk.yellow("\n⚠ Existing keys detected:") +
32
+ (hasExistingPublicKey
33
+ ? chalk.dim(`\n ENVGAD_PUBLIC_KEY found in ${envPath}`)
34
+ : "") +
35
+ (hasExistingKeysFile
36
+ ? chalk.dim(`\n ${keysPath} already exists`)
37
+ : "") +
38
+ chalk.yellow("\n\n Generating new keys invalidates all existing encrypted values.\n" +
39
+ " Use 'npx dotenv-gad rotate' for safe key rotation.\n"));
40
+ const ok = await confirm(" Generate new keys anyway? (y/N): ");
41
+ if (!ok) {
42
+ console.log(chalk.dim("\nAborted."));
43
+ return;
44
+ }
45
+ console.log();
46
+ }
47
+ const spinner = ora("Generating X25519 key pair…").start();
48
+ const { publicKeyHex, privateKeyHex } = generateKeyPair();
49
+ // Update or append public key in .env
50
+ let newEnvContent;
51
+ if (hasExistingPublicKey) {
52
+ newEnvContent = envContent.replace(/^ENVGAD_PUBLIC_KEY=.*$/m, `ENVGAD_PUBLIC_KEY=${publicKeyHex}`);
53
+ }
54
+ else {
55
+ const sep = envContent.length > 0 && !envContent.endsWith("\n") ? "\n" : "";
56
+ newEnvContent =
57
+ envContent +
58
+ `${sep}\n# dotenv-gad encryption public key (safe to commit)\n` +
59
+ `ENVGAD_PUBLIC_KEY=${publicKeyHex}\n`;
60
+ }
61
+ writeFileSync(envPath, newEnvContent);
62
+ // Write private key to .env.keys (restricted permissions on Unix)
63
+ writeFileSync(keysPath, "# KEEP THIS FILE SECRET — DO NOT COMMIT TO GIT\n" +
64
+ "# Share securely via 1Password, Vault, or a secure channel\n" +
65
+ `ENVGAD_PRIVATE_KEY=${privateKeyHex}\n`, { mode: 0o600 });
66
+ // Ensure .env.keys is listed in .gitignore
67
+ const gitignorePath = ".gitignore";
68
+ const gitignoreContent = existsSync(gitignorePath)
69
+ ? readFileSync(gitignorePath, "utf8")
70
+ : "";
71
+ if (!gitignoreContent.includes(keysPath)) {
72
+ const sep = gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n") ? "\n" : "";
73
+ appendFileSync(gitignorePath, `${sep}\n# dotenv-gad encryption keys\n${keysPath}\n`);
74
+ }
75
+ spinner.succeed(chalk.green("Key pair generated!"));
76
+ console.log("\n" +
77
+ chalk.bold("Public key") +
78
+ chalk.dim(` → ${envPath}`) +
79
+ `\n ${chalk.cyan(`ENVGAD_PUBLIC_KEY=${publicKeyHex}`)}\n` +
80
+ "\n" +
81
+ chalk.bold("Private key") +
82
+ chalk.dim(` → ${keysPath}`) +
83
+ `\n ${chalk.cyan(`ENVGAD_PRIVATE_KEY=${privateKeyHex}`)}\n` +
84
+ "\n" +
85
+ chalk.yellow("⚠ Remember:\n") +
86
+ chalk.dim(` • ${keysPath} is gitignored — never commit it\n`) +
87
+ chalk.dim(" • Share it securely with team members\n") +
88
+ chalk.dim(" • For CI/CD, set ENVGAD_PRIVATE_KEY as an environment secret\n") +
89
+ "\n" +
90
+ chalk.bold("Next steps:\n") +
91
+ chalk.dim(" 1. Add ") +
92
+ chalk.cyan("encrypted: true") +
93
+ chalk.dim(" to sensitive fields in your schema\n") +
94
+ chalk.dim(" 2. Run: ") +
95
+ chalk.cyan("npx dotenv-gad encrypt"));
96
+ });
97
+ }