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
@@ -0,0 +1,160 @@
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, copyFileSync } from "node:fs";
6
+ import dotenv from "dotenv";
7
+ import { generateKeyPair, encryptEnvValue, decryptEnvValue, isEncryptedValue, loadPrivateKey, } from "../../crypto.js";
8
+ import { loadSchema } from "./utils.js";
9
+ async function confirm(question) {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11
+ try {
12
+ const answer = await rl.question(question);
13
+ return answer.trim().toLowerCase() === "y";
14
+ }
15
+ finally {
16
+ rl.close();
17
+ }
18
+ }
19
+ export default function (_program) {
20
+ return new Command("rotate")
21
+ .description("Rotate encryption keys: decrypt all fields, generate a new key pair, re-encrypt")
22
+ .option("--keys <file>", "Path to the .env.keys file", ".env.keys")
23
+ .option("-f, --force", "Skip confirmation prompt")
24
+ .action(async (opts, command) => {
25
+ const rootOpts = command.parent.opts();
26
+ const envPath = rootOpts.env ?? ".env";
27
+ const schemaPath = rootOpts.schema ?? "env.schema.ts";
28
+ const keysPath = opts.keys;
29
+ const spinner = ora("Loading schema…").start();
30
+ try {
31
+ const schema = await loadSchema(schemaPath);
32
+ const encryptedFields = Object.keys(schema).filter((k) => schema[k].encrypted === true);
33
+ if (encryptedFields.length === 0) {
34
+ spinner.info(chalk.dim("No fields with encrypted: true in schema. Nothing to rotate."));
35
+ return;
36
+ }
37
+ spinner.text = `Reading ${envPath}…`;
38
+ let envContent;
39
+ try {
40
+ envContent = readFileSync(envPath, "utf8");
41
+ }
42
+ catch {
43
+ spinner.fail(chalk.red(`Cannot read ${envPath}`));
44
+ process.exit(1);
45
+ }
46
+ const parsed = dotenv.parse(envContent);
47
+ spinner.text = "Loading current private key…";
48
+ const oldPrivateKeyHex = loadPrivateKey({ keysPath });
49
+ if (!oldPrivateKeyHex) {
50
+ spinner.fail(chalk.red("Current private key not found.\n" +
51
+ ` Checked: ${keysPath} and ENVGAD_PRIVATE_KEY env var\n` +
52
+ " Cannot rotate without the current private key."));
53
+ process.exit(1);
54
+ }
55
+ // Identify which fields have encrypted values in the .env file
56
+ const toRotate = encryptedFields.filter((k) => parsed[k] && isEncryptedValue(parsed[k]));
57
+ spinner.stop();
58
+ if (toRotate.length === 0) {
59
+ console.log(chalk.dim("No encrypted values found in .env to rotate."));
60
+ return;
61
+ }
62
+ console.log(chalk.bold(`\nKey rotation will affect ${toRotate.length} field(s):\n`) +
63
+ toRotate.map((k) => chalk.dim(` • ${k}`)).join("\n") +
64
+ "\n\n" +
65
+ chalk.yellow("This operation will:\n") +
66
+ chalk.dim(" 1. Decrypt all encrypted values with the current private key\n") +
67
+ chalk.dim(" 2. Generate a new X25519 key pair\n") +
68
+ chalk.dim(" 3. Re-encrypt all values with the new public key\n") +
69
+ chalk.dim(" 4. Write the new public key to " + envPath + "\n") +
70
+ chalk.dim(" 5. Save the old private key as ENVGAD_PRIVATE_KEY_OLD in " + keysPath + "\n") +
71
+ chalk.dim(" 6. Write the new private key to " + keysPath + "\n"));
72
+ if (!opts.force) {
73
+ const ok = await confirm(" Proceed with key rotation? (y/N): ");
74
+ if (!ok) {
75
+ console.log(chalk.dim("\nAborted."));
76
+ return;
77
+ }
78
+ console.log();
79
+ }
80
+ const spinner2 = ora("Step 1/4: Decrypting current values…").start();
81
+ // Decrypt all encrypted values with the old key
82
+ const decrypted = {};
83
+ const decryptErrors = [];
84
+ for (const key of toRotate) {
85
+ try {
86
+ decrypted[key] = decryptEnvValue(parsed[key], oldPrivateKeyHex, key);
87
+ }
88
+ catch (err) {
89
+ decryptErrors.push(`${key}: ${err instanceof Error ? err.message : "decryption failed"}`);
90
+ }
91
+ }
92
+ if (decryptErrors.length > 0) {
93
+ spinner2.fail(chalk.red("Decryption failed for some fields. Aborting rotation."));
94
+ for (const e of decryptErrors) {
95
+ console.error(chalk.red(` ✗ ${e}`));
96
+ }
97
+ console.log(chalk.dim("\nNo changes were written. Your .env and .env.keys are unchanged."));
98
+ process.exit(1);
99
+ }
100
+ spinner2.text = "Step 2/4: Generating new key pair…";
101
+ // Generate new key pair
102
+ const { publicKeyHex: newPublicKeyHex, privateKeyHex: newPrivateKeyHex } = generateKeyPair();
103
+ spinner2.text = "Step 3/4: Re-encrypting values with new key…";
104
+ // Re-encrypt all values with the new public key
105
+ const reEncrypted = {};
106
+ for (const key of toRotate) {
107
+ reEncrypted[key] = encryptEnvValue(decrypted[key], newPublicKeyHex, key);
108
+ }
109
+ spinner2.text = "Step 4/4: Writing updated files…";
110
+ // Update .env — replace all encrypted values and the public key
111
+ let updatedEnvContent = envContent;
112
+ // Update ENVGAD_PUBLIC_KEY
113
+ if (/^ENVGAD_PUBLIC_KEY=/m.test(updatedEnvContent)) {
114
+ updatedEnvContent = updatedEnvContent.replace(/^ENVGAD_PUBLIC_KEY=.*$/m, `ENVGAD_PUBLIC_KEY=${newPublicKeyHex}`);
115
+ }
116
+ else {
117
+ const sep = updatedEnvContent.endsWith("\n") ? "" : "\n";
118
+ updatedEnvContent += `${sep}ENVGAD_PUBLIC_KEY=${newPublicKeyHex}\n`;
119
+ }
120
+ const replaceRegexes = Object.fromEntries(Object.keys(reEncrypted).map((k) => [k, new RegExp(`^(${k}\\s*=).*$`, "m")]));
121
+ for (const [key, value] of Object.entries(reEncrypted)) {
122
+ updatedEnvContent = updatedEnvContent.replace(replaceRegexes[key], `$1${value}`);
123
+ }
124
+ copyFileSync(envPath, `${envPath}.bak`);
125
+ writeFileSync(envPath, updatedEnvContent);
126
+ // Update .env.keys — keep old key as ENVGAD_PRIVATE_KEY_OLD
127
+ const keysFileContent = "# KEEP THIS FILE SECRET — DO NOT COMMIT TO GIT\n" +
128
+ "# Share securely via 1Password, Vault, or a secure channel\n" +
129
+ `ENVGAD_PRIVATE_KEY=${newPrivateKeyHex}\n` +
130
+ `\n# Previous key (kept for emergency access)\n` +
131
+ `ENVGAD_PRIVATE_KEY_OLD=${oldPrivateKeyHex}\n`;
132
+ // Back up old .env.keys before overwriting
133
+ if (existsSync(keysPath)) {
134
+ copyFileSync(keysPath, `${keysPath}.bak`);
135
+ }
136
+ writeFileSync(keysPath, keysFileContent, { mode: 0o600 });
137
+ spinner2.succeed(chalk.green("Key rotation complete!"));
138
+ console.log(`\n${chalk.green("✓")} ${toRotate.length} field(s) re-encrypted in ${chalk.bold(envPath)}\n` +
139
+ `${chalk.green("✓")} New public key written to ${chalk.bold(envPath)}\n` +
140
+ `${chalk.green("✓")} New private key written to ${chalk.bold(keysPath)}\n` +
141
+ `${chalk.dim(`✓ Previous private key preserved as ENVGAD_PRIVATE_KEY_OLD in ${keysPath}`)}\n` +
142
+ chalk.dim(`✓ Backup of old ${envPath} saved as ${envPath}.bak\n`) +
143
+ (existsSync(`${keysPath}.bak`)
144
+ ? chalk.dim(`✓ Backup of old ${keysPath} saved as ${keysPath}.bak\n`)
145
+ : "") +
146
+ "\n" +
147
+ chalk.yellow("Next steps:\n") +
148
+ chalk.dim(" • Distribute the new .env.keys to team members\n") +
149
+ chalk.dim(" • Update ENVGAD_PRIVATE_KEY in CI/CD secrets\n") +
150
+ chalk.dim(" • Run: ") +
151
+ chalk.cyan("npx dotenv-gad verify") +
152
+ chalk.dim(" to confirm decryption works with the new key"));
153
+ }
154
+ catch (error) {
155
+ spinner.stop();
156
+ console.error(chalk.red("Unexpected error:"), error);
157
+ process.exit(2);
158
+ }
159
+ });
160
+ }
@@ -0,0 +1,116 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import dotenv from "dotenv";
6
+ import { isEncryptedValue, loadPrivateKey } from "../../crypto.js";
7
+ import { loadSchema } from "./utils.js";
8
+ export default function (_program) {
9
+ return new Command("status")
10
+ .description("Show encryption status of all schema fields in the .env file")
11
+ .option("--keys <file>", "Path to .env.keys file", ".env.keys")
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 keysPath = opts.keys;
17
+ const spinner = ora("Loading schema…").start();
18
+ try {
19
+ const schema = await loadSchema(schemaPath);
20
+ let envContent = "";
21
+ if (existsSync(envPath)) {
22
+ envContent = readFileSync(envPath, "utf8");
23
+ }
24
+ const parsed = dotenv.parse(envContent);
25
+ // Check key availability
26
+ const hasPublicKey = Boolean(parsed.ENVGAD_PUBLIC_KEY);
27
+ const privateKeyHex = loadPrivateKey({ keysPath });
28
+ spinner.stop();
29
+ const schemaKeys = Object.keys(schema);
30
+ const encryptedSchemaKeys = new Set(schemaKeys.filter((k) => schema[k].encrypted));
31
+ console.log(chalk.bold("\nEnvironment Encryption Status"));
32
+ console.log(chalk.dim("─".repeat(52)));
33
+ let correctCount = 0;
34
+ let warningCount = 0;
35
+ for (const key of schemaKeys) {
36
+ const rule = schema[key];
37
+ const value = parsed[key];
38
+ const needsEncryption = rule.encrypted === true;
39
+ const valueIsEncrypted = value ? isEncryptedValue(value) : false;
40
+ const valueMissing = value == null || value === "";
41
+ let icon;
42
+ let label;
43
+ let note;
44
+ if (valueMissing) {
45
+ if (needsEncryption) {
46
+ icon = chalk.dim("○");
47
+ label = chalk.dim("missing");
48
+ note = chalk.dim("(encrypted when present)");
49
+ }
50
+ else {
51
+ icon = chalk.dim("○");
52
+ label = chalk.dim("missing");
53
+ note = "";
54
+ }
55
+ }
56
+ else if (needsEncryption && valueIsEncrypted) {
57
+ icon = chalk.green("✓");
58
+ label = chalk.green("encrypted");
59
+ note = chalk.dim("(correct)");
60
+ correctCount++;
61
+ }
62
+ else if (needsEncryption && !valueIsEncrypted) {
63
+ icon = chalk.yellow("⚠");
64
+ label = chalk.yellow("plaintext");
65
+ note = chalk.yellow("(should be encrypted — run: npx dotenv-gad encrypt)");
66
+ warningCount++;
67
+ }
68
+ else if (!needsEncryption && valueIsEncrypted) {
69
+ icon = chalk.red("✗");
70
+ label = chalk.red("encrypted");
71
+ note = chalk.red("(schema lacks encrypted: true)");
72
+ warningCount++;
73
+ }
74
+ else {
75
+ icon = chalk.green("✓");
76
+ label = chalk.dim("plaintext");
77
+ note = chalk.dim("(no encryption required)");
78
+ correctCount++;
79
+ }
80
+ const keyDisplay = chalk.bold(key.padEnd(24));
81
+ console.log(` ${icon} ${keyDisplay} ${label} ${note}`);
82
+ }
83
+ console.log(chalk.dim("─".repeat(52)));
84
+ // Key availability summary
85
+ console.log();
86
+ if (hasPublicKey) {
87
+ console.log(` ${chalk.green("✓")} ${chalk.dim("Public key (ENVGAD_PUBLIC_KEY): present in " + envPath)}`);
88
+ }
89
+ else {
90
+ console.log(` ${chalk.yellow("⚠")} ${chalk.yellow("Public key (ENVGAD_PUBLIC_KEY): missing — run: npx dotenv-gad keygen")}`);
91
+ }
92
+ if (privateKeyHex) {
93
+ const src = existsSync(keysPath) ? keysPath : "ENVGAD_PRIVATE_KEY env var";
94
+ console.log(` ${chalk.green("✓")} ${chalk.dim("Private key (ENVGAD_PRIVATE_KEY): found in " + src)}`);
95
+ }
96
+ else {
97
+ console.log(` ${chalk.dim("○")} ${chalk.dim("Private key (ENVGAD_PRIVATE_KEY): not found (needed for decryption only)")}`);
98
+ }
99
+ console.log();
100
+ const encryptedTotal = encryptedSchemaKeys.size;
101
+ if (warningCount > 0) {
102
+ console.log(chalk.yellow(` ${warningCount} issue(s) detected`) +
103
+ chalk.dim(`, ${correctCount} correct, ${encryptedTotal} field(s) require encryption`));
104
+ process.exit(1);
105
+ }
106
+ else {
107
+ console.log(chalk.green(` All ${schemaKeys.length} field(s) are correctly configured`));
108
+ }
109
+ }
110
+ catch (error) {
111
+ spinner.stop();
112
+ console.error(chalk.red("Unexpected error:"), error);
113
+ process.exit(2);
114
+ }
115
+ });
116
+ }
@@ -3,7 +3,7 @@ import chalk from "chalk";
3
3
  import ora from "ora";
4
4
  import { writeFileSync } from "fs";
5
5
  import { loadSchema } from "./utils.js";
6
- export default function (program) {
6
+ export default function (_program) {
7
7
  return new Command("sync")
8
8
  .description("Generate/update .env.example file")
9
9
  .option("--output <file>", "Output file path", ".env.example")
@@ -15,39 +15,37 @@ export default function (program) {
15
15
  if (!schema || typeof schema !== "object") {
16
16
  throw new Error(`The schema loaded from "${rootOpts.schema}" is not valid.`);
17
17
  }
18
- let exampleContent = "# Auto-generated by dotenv-gad\n\n";
18
+ const parts = ["# Auto-generated by dotenv-gad\n\n"];
19
19
  Object.entries(schema).forEach(([key, rule]) => {
20
20
  if (rule.sensitive)
21
21
  return;
22
- // If this is a grouped object with properties, emit grouped entries
23
22
  const eff = rule;
23
+ // If this is a grouped object with properties, emit grouped entries
24
24
  if (eff.type === "object" && eff.properties) {
25
25
  const prefix = eff.envPrefix || `${key}_`;
26
- exampleContent += `# ${eff.docs || "No description available"}\n`;
27
- exampleContent += `# Group: ${key} (prefix=${prefix})\n`;
26
+ parts.push(`# ${eff.docs || "No description available"}\n`);
27
+ parts.push(`# Group: ${key} (prefix=${prefix})\n`);
28
28
  Object.entries(eff.properties).forEach(([prop, pRule]) => {
29
29
  const pr = pRule;
30
30
  if (pr.sensitive)
31
31
  return;
32
- exampleContent += `# ${pr.docs || "No description available"}\n`;
33
- exampleContent += `# Type: ${pr.type}\n`;
32
+ parts.push(`# ${pr.docs || "No description available"}\n`);
33
+ parts.push(`# Type: ${pr.type}\n`);
34
34
  if (pr.default !== undefined) {
35
- exampleContent += `# Default: ${JSON.stringify(pr.default)}\n`;
35
+ parts.push(`# Default: ${JSON.stringify(pr.default)}\n`);
36
36
  }
37
- exampleContent += `${prefix}${prop}=${pr.default ? JSON.stringify(pr.default) : ""}\n\n`;
37
+ parts.push(`${prefix}${prop}=${pr.default ? JSON.stringify(pr.default) : ""}\n\n`);
38
38
  });
39
39
  return;
40
40
  }
41
- exampleContent += `# ${rule.docs || "No description available"}\n`;
42
- exampleContent += `# Type: ${rule.type}\n`;
43
- if (rule.default !== undefined) {
44
- exampleContent += `# Default: ${JSON.stringify(rule.default)}\n`;
41
+ parts.push(`# ${eff.docs || "No description available"}\n`);
42
+ parts.push(`# Type: ${eff.type}\n`);
43
+ if (eff.default !== undefined) {
44
+ parts.push(`# Default: ${JSON.stringify(eff.default)}\n`);
45
45
  }
46
- exampleContent += `${key}=${rule.default
47
- ? JSON.stringify(rule.default)
48
- : ""}\n\n`;
46
+ parts.push(`${key}=${eff.default ? JSON.stringify(eff.default) : ""}\n\n`);
49
47
  });
50
- writeFileSync(options.output, exampleContent.trim());
48
+ writeFileSync(options.output, parts.join("").trimEnd());
51
49
  spinner.succeed(chalk.green(`Generated ${options.output} successfully!`));
52
50
  }
53
51
  catch (error) {
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from "fs";
2
+ import { createInterface } from "node:readline/promises";
2
3
  import Chalk from "chalk";
3
- import inquirer from "inquirer";
4
4
  // Re-export from the standalone schema loader (no CLI-only deps)
5
5
  export { loadSchema } from "../../schema-loader.js";
6
6
  /**
@@ -16,37 +16,43 @@ export { loadSchema } from "../../schema-loader.js";
16
16
  */
17
17
  export async function applyFix(issues, schema, envPath = ".env") {
18
18
  const envLines = readFileSync(envPath, "utf-8").split("\n");
19
- for (const key in issues) {
20
- if (!Object.prototype.hasOwnProperty.call(issues, key))
21
- continue;
22
- const issue = issues[key];
23
- const rule = schema[key];
24
- if (!rule) {
25
- console.error(Chalk.red(`Error: Could not find rule for key ${key} in schema`));
26
- continue;
27
- }
28
- const { value } = await inquirer.prompt({
29
- type: "input",
30
- name: "value",
31
- message: `${Chalk.yellow(key)} (${rule.docs || "No description"})`,
32
- default: rule.default !== undefined ? String(rule.default) : "",
33
- validate: (input) => {
19
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
20
+ try {
21
+ for (const key in issues) {
22
+ if (!Object.prototype.hasOwnProperty.call(issues, key))
23
+ continue;
24
+ const rule = schema[key];
25
+ if (!rule) {
26
+ console.error(Chalk.red(`Error: Could not find rule for key ${key} in schema`));
27
+ continue;
28
+ }
29
+ const defaultValue = rule.default !== undefined ? String(rule.default) : "";
30
+ const hint = defaultValue ? Chalk.dim(` [${defaultValue}]`) : "";
31
+ const prompt = `${Chalk.yellow(key)} (${rule.docs || "No description"})${hint}: `;
32
+ let input;
33
+ while (true) {
34
+ const answer = await rl.question(prompt);
35
+ input = answer.trim() || defaultValue;
34
36
  if (rule.required && !input) {
35
- return "Value is required";
37
+ console.log(Chalk.red(" Value is required"));
38
+ continue;
36
39
  }
37
- return true;
38
- },
39
- });
40
- // Sanitize: strip newlines and carriage returns to prevent .env injection
41
- const sanitized = String(value).replace(/[\r\n]/g, "");
42
- const lineIndex = envLines.findIndex((line) => line.startsWith(`${key}=`));
43
- if (lineIndex >= 0) {
44
- envLines[lineIndex] = `${key}=${sanitized}`;
45
- }
46
- else {
47
- envLines.push(`${key}=${sanitized}`);
40
+ break;
41
+ }
42
+ // Sanitize: strip newlines and carriage returns to prevent .env injection
43
+ const sanitized = input.replace(/[\r\n]/g, "");
44
+ const lineIndex = envLines.findIndex((line) => line.startsWith(`${key}=`));
45
+ if (lineIndex >= 0) {
46
+ envLines[lineIndex] = `${key}=${sanitized}`;
47
+ }
48
+ else {
49
+ envLines.push(`${key}=${sanitized}`);
50
+ }
48
51
  }
49
52
  }
53
+ finally {
54
+ rl.close();
55
+ }
50
56
  writeFileSync(envPath, envLines.join("\n"));
51
57
  console.log(Chalk.green(`Updated ${envPath} successfully!`));
52
58
  }
@@ -0,0 +1,101 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import dotenv from "dotenv";
6
+ import { decryptEnvValue, isEncryptedValue, loadPrivateKey } from "../../crypto.js";
7
+ import { EncryptionKeyMissingError } from "../../errors.js";
8
+ import { loadSchema } from "./utils.js";
9
+ export default function (_program) {
10
+ return new Command("verify")
11
+ .description("Verify all encrypted fields can be decrypted with the current private key (no output)")
12
+ .option("--keys <file>", "Path to .env.keys file", ".env.keys")
13
+ .action(async (opts, command) => {
14
+ const rootOpts = command.parent.opts();
15
+ const envPath = rootOpts.env ?? ".env";
16
+ const schemaPath = rootOpts.schema ?? "env.schema.ts";
17
+ const keysPath = opts.keys;
18
+ const spinner = ora("Loading schema…").start();
19
+ try {
20
+ const schema = await loadSchema(schemaPath);
21
+ const encryptedFields = Object.keys(schema).filter((k) => schema[k].encrypted === true);
22
+ if (encryptedFields.length === 0) {
23
+ spinner.info(chalk.dim("No fields with encrypted: true in schema. Nothing to verify."));
24
+ return;
25
+ }
26
+ spinner.text = `Reading ${envPath}…`;
27
+ let envContent = "";
28
+ try {
29
+ envContent = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
30
+ }
31
+ catch {
32
+ spinner.fail(chalk.red(`Cannot read ${envPath}`));
33
+ process.exit(1);
34
+ }
35
+ const parsed = dotenv.parse(envContent);
36
+ spinner.text = "Loading private key…";
37
+ const privateKeyHex = loadPrivateKey({ keysPath });
38
+ if (!privateKeyHex) {
39
+ spinner.fail(chalk.red("Private key not found.\n" +
40
+ ` Checked: ${keysPath} and ENVGAD_PRIVATE_KEY env var`));
41
+ process.exit(1);
42
+ }
43
+ spinner.text = `Verifying ${encryptedFields.length} encrypted field(s)…`;
44
+ const results = [];
45
+ for (const key of encryptedFields) {
46
+ const value = parsed[key];
47
+ if (value == null || value === "") {
48
+ // No value present — skip (required check is handled by validate)
49
+ continue;
50
+ }
51
+ if (!isEncryptedValue(value)) {
52
+ results.push({ key, ok: false, reason: "value is plaintext (not encrypted)" });
53
+ continue;
54
+ }
55
+ try {
56
+ decryptEnvValue(value, privateKeyHex, key);
57
+ results.push({ key, ok: true });
58
+ }
59
+ catch (err) {
60
+ results.push({
61
+ key,
62
+ ok: false,
63
+ reason: err instanceof Error ? err.message : "Decryption failed",
64
+ });
65
+ }
66
+ }
67
+ spinner.stop();
68
+ const failed = results.filter((r) => !r.ok);
69
+ const passed = results.filter((r) => r.ok);
70
+ for (const { key, ok, reason } of results) {
71
+ if (ok) {
72
+ console.log(` ${chalk.green("✓")} ${chalk.bold(key)}`);
73
+ }
74
+ else {
75
+ console.log(` ${chalk.red("✗")} ${chalk.bold(key)}: ${chalk.red(reason ?? "failed")}`);
76
+ }
77
+ }
78
+ console.log();
79
+ if (failed.length > 0) {
80
+ console.log(chalk.red(`✗ ${failed.length} field(s) failed verification`) +
81
+ chalk.dim(`, ${passed.length} passed`));
82
+ process.exit(1);
83
+ }
84
+ else if (passed.length === 0) {
85
+ console.log(chalk.dim("No encrypted values found in .env to verify."));
86
+ }
87
+ else {
88
+ console.log(chalk.green(`✓ All ${passed.length} encrypted field(s) verified successfully`));
89
+ }
90
+ }
91
+ catch (error) {
92
+ spinner.stop();
93
+ if (error instanceof EncryptionKeyMissingError) {
94
+ console.error(chalk.red(`\n✗ ${error.message}`));
95
+ process.exit(1);
96
+ }
97
+ console.error(chalk.red("Unexpected error:"), error);
98
+ process.exit(2);
99
+ }
100
+ });
101
+ }
package/dist/cli/index.js CHANGED
@@ -11,6 +11,12 @@ import typesCommand from "./commands/types.js";
11
11
  import initCommand from "./commands/init.js";
12
12
  import fixCommand from "./commands/fix.js";
13
13
  import docsCommand from "./commands/docs.js";
14
+ import keygenCommand from "./commands/keygen.js";
15
+ import encryptCommand from "./commands/encrypt.js";
16
+ import decryptCommand from "./commands/decrypt.js";
17
+ import statusCommand from "./commands/status.js";
18
+ import verifyCommand from "./commands/verify.js";
19
+ import rotateCommand from "./commands/rotate.js";
14
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
21
  const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf-8"));
16
22
  export function createCLI() {
@@ -31,6 +37,12 @@ export function createCLI() {
31
37
  initCommand,
32
38
  fixCommand,
33
39
  docsCommand,
40
+ keygenCommand,
41
+ encryptCommand,
42
+ decryptCommand,
43
+ statusCommand,
44
+ verifyCommand,
45
+ rotateCommand,
34
46
  ];
35
47
  commands.forEach((command) => {
36
48
  const cmd = command(program);